diff --git a/README.md b/README.md
index 2c0acefb..f0b7752d 100644
--- a/README.md
+++ b/README.md
@@ -53,12 +53,20 @@ requirements and the exact coordination guarantees.
With Local Markdown, `wrighty web` provides the board, item editor, recovery actions, and local
worker visibility:
-[](docs/assets/screenshots/local-markdown-web-ui-board.png)
+
+
+
+
+
+
+
+
+
With the GitHub backend, the configured GitHub Project remains the shared board. Wrighty adds the
queue, policy, claim, and recovery state used by local workers on each computer:
-[](docs/assets/screenshots/github-board.png)
+
See [Wrighty workflows](docs/workflows.md) for the actions behind these views and the
[Web console](docs/reference/web-console.md) for the backend-specific web surfaces.
diff --git a/docs/assets/screenshots/local-markdown-web-ui-board-agents.png b/docs/assets/screenshots/local-markdown-web-ui-board-agents.png
new file mode 100644
index 00000000..9d9be800
Binary files /dev/null and b/docs/assets/screenshots/local-markdown-web-ui-board-agents.png differ
diff --git a/docs/assets/screenshots/local-markdown-web-ui-board-needs-attention.png b/docs/assets/screenshots/local-markdown-web-ui-board-needs-attention.png
new file mode 100644
index 00000000..bd969ff1
Binary files /dev/null and b/docs/assets/screenshots/local-markdown-web-ui-board-needs-attention.png differ
diff --git a/docs/assets/screenshots/local-markdown-web-ui-board-workers.png b/docs/assets/screenshots/local-markdown-web-ui-board-workers.png
new file mode 100644
index 00000000..6028f3cb
Binary files /dev/null and b/docs/assets/screenshots/local-markdown-web-ui-board-workers.png differ
diff --git a/docs/assets/screenshots/local-markdown-web-ui-board.png b/docs/assets/screenshots/local-markdown-web-ui-board.png
index 23ddd916..b67fb879 100644
Binary files a/docs/assets/screenshots/local-markdown-web-ui-board.png and b/docs/assets/screenshots/local-markdown-web-ui-board.png differ
diff --git a/docs/assets/screenshots/local-markdown-web-ui-item-attention.png b/docs/assets/screenshots/local-markdown-web-ui-item-attention.png
deleted file mode 100644
index 2ee4a302..00000000
Binary files a/docs/assets/screenshots/local-markdown-web-ui-item-attention.png and /dev/null differ
diff --git a/docs/assets/screenshots/local-markdown-web-ui-item-attention2.png b/docs/assets/screenshots/local-markdown-web-ui-item-attention2.png
deleted file mode 100644
index 1c5c0422..00000000
Binary files a/docs/assets/screenshots/local-markdown-web-ui-item-attention2.png and /dev/null differ
diff --git a/docs/assets/screenshots/local-markdown-web-ui-item-create.png b/docs/assets/screenshots/local-markdown-web-ui-item-create.png
deleted file mode 100644
index 51f201dd..00000000
Binary files a/docs/assets/screenshots/local-markdown-web-ui-item-create.png and /dev/null differ
diff --git a/docs/assets/screenshots/local-markdown-web-ui-operations.png b/docs/assets/screenshots/local-markdown-web-ui-operations.png
index e0ed1199..63ac6d69 100644
Binary files a/docs/assets/screenshots/local-markdown-web-ui-operations.png and b/docs/assets/screenshots/local-markdown-web-ui-operations.png differ
diff --git a/docs/reference/execution-profiles.md b/docs/reference/execution-profiles.md
index 6e7cfbfa..4b7634ef 100644
--- a/docs/reference/execution-profiles.md
+++ b/docs/reference/execution-profiles.md
@@ -103,8 +103,10 @@ wrighty edit --profile economy --claimant-id --claim-token
wrighty edit --clear-profile --claimant-id --claim-token
```
-On the web console the item editor offers **Execution profile** beside **Agent policy**,
-whenever the repository configures a vocabulary.
+On the web console the create and edit item forms offer **Execution profile** beside **Agent policy**.
+They show the repository vocabulary when configured, or the built-in `economy`, `balanced`, and
+`deep` names otherwise. The repository-default option includes the configured default profile name
+when one exists, or says `vendor defaults` when Wrighty will pass no model or effort override.
**Resolution fails closed.** A profile that resolves to nothing usable is an error
(`AGENT_PROFILE_UNAVAILABLE`), never a quiet fallback. Wrighty does not drop to a cheaper profile to
diff --git a/docs/reference/web-console.md b/docs/reference/web-console.md
index e2244ee6..cf25903d 100644
--- a/docs/reference/web-console.md
+++ b/docs/reference/web-console.md
@@ -47,10 +47,19 @@ not replace the board.
## Start, observe, and stop workers
-**Operations → Local worker processes** shows both kinds of local worker:
+The header's **Workers** button opens an installation-local anchored popover, matching the
+**Agents** control. It lists running
+workers, distinguishes **Worker preparing** from **Agent working**, shows the current item, agent,
+runtime, and heartbeat, and offers a confirmed graceful **Stop** plus **Details**. **Details** opens
+Operations at that exact worker and briefly highlights it. **Start new worker** adds a worker owned
+by the current web-console process. Stale or unverifiable registrations stay out of the running
+list and are linked to the complete Operations view.
+
+**Operations → Local worker processes** remains the complete surface and shows both kinds of local
+worker:
- **Hosted by this web console** is a background task owned by the current `wrighty web` process.
- Every **Start worker** action adds another generic continuous worker using the configuration
+ Every **Start worker** or **Start new worker** action adds another generic continuous worker using the configuration
snapshot loaded at web startup. Closing, refreshing, or navigating away from the browser does
not stop them. Stopping the `wrighty web` process does.
- **Started outside the web console** is a `wrighty worker` process launched by a terminal, service
@@ -61,8 +70,9 @@ and current agent. Older worker versions that did not publish the agent say so i
from the item or claim. Stale and unverifiable records label item, agent, and state as last-reported
facts. The header's **Workers** button reports the verified running count from every tab, labels an
idle pool, and highlights how many workers are actively processing an item. Stale or unverifiable
-registrations are called out separately. Select the button to open and focus **Operations → Local
-worker processes**. The current hosted worker card exposes a bounded structured operational log. Opening it
+registrations are called out separately. Select the button to open the running-worker overview;
+use **Details** or **View all in Operations** for the complete process controls. The current hosted
+worker card exposes a bounded structured operational log. Opening it
starts at the newest event. Normal and manual Operations refreshes continue updating the whole
worker card while preserving the disclosure and its scroll position for the same run. Updates
follow the tail until the operator scrolls back, at which point Wrighty preserves that reading
@@ -116,6 +126,13 @@ Operations organizes a bounded window. Wrighty asks the backend for one item bey
and the visible count describe the loaded 100-item window, not the entire repository. Refine the
filters or use the backend's native tracker when repository-wide discovery is required.
+Board cards distinguish a worker that has claimed an item from one that has started its agent. A
+neutral **Worker preparing** card means the worker owns the claim while it prepares the workspace
+and validates the launch. After the operating-system process starts, the card changes to a raised,
+outlined **Live — Agent working** treatment with a full-width execution banner, using the selected
+agent's display name. Its motion becomes static when the browser requests reduced motion. Older
+claims that do not record an execution phase continue to display as working for compatibility.
+
## Open a retained session from Operations
When an operational item needs attention, or is Done with no active claim, and this installation
@@ -327,22 +344,28 @@ active-work, completion, and archive-triggering statuses are excluded and reject
With worker-queue authorization enabled, status owns execution eligibility: creation in
`defaultPickFrom` authorizes execution and the form shows that rule instead of an independent
checkbox. With queue authorization disabled, the form offers **Allow automatic execution**, off by
-default. An agent policy does not imply eligibility. **Create item** uses the ordinary retry-safe
-creation pipeline. It never claims the new item, starts a worker, or launches a vendor agent.
+default. The form also offers the item's agent and execution-profile policies; neither implies
+eligibility. **Create item** uses the ordinary retry-safe creation pipeline. It never claims the new
+item, starts a worker, or launches a vendor agent.
The item editor's **Execution policy** section explains status-controlled authorization when the
worker queue owns that decision; otherwise it offers the per-item automatic-execution checkbox. It
-also carries agent policy and — when the repository configures an execution-profile vocabulary —
-**Execution profile**. A repository that does not use profiles sees no such control. The choice
-applies to the item's next fresh run; a recorded session keeps the model and effort it started with. See
-[Execution profiles](execution-profiles.md).
+also carries **Agent policy** and **Execution profile**. The profile choices come from the repository
+vocabulary when configured, or from the built-in `economy`, `balanced`, and `deep` names otherwise.
+For both policies, the repository-default choice includes the configured value when one exists; an
+execution profile with no repository default says **vendor defaults**, meaning Wrighty passes no
+model or effort override. The item viewer reports both policies with the same repository-default
+labels. A profile choice applies to the item's next fresh run; a recorded session keeps the model and
+effort it started with. See [Execution profiles](execution-profiles.md).
The web console also shows configured status columns, priority and claim state, supports
active/archived filtering, and renders each item's Markdown. The Board-wide sort offers operational
priority, item number, creation/update time, configured priority rank, and title. A compact control
in each status column can override that default; choose **Board sort** there to clear the override.
Every explicit order uses item number as its stable tie-break, and missing values remain last in
-both directions.
+both directions. The default operational order keeps scarce live work visible above a potentially
+large backlog: **Agent working**, **Worker preparing**, **Needs attention**, retry scheduled,
+handoff queued, resume queued, then other items. Operations uses the same default order.
Structured Board filters narrow claimant kind, associated agent, priority, claim ownership,
and update recency. The associated agent is the active claim's agent when present, then the retained
diff --git a/docs/reference/worker.md b/docs/reference/worker.md
index 081e41fe..d0d2bfdb 100644
--- a/docs/reference/worker.md
+++ b/docs/reference/worker.md
@@ -1125,7 +1125,9 @@ wrighty status --json # same groups for scripting
- **Completed — retained worktree** — finished items whose worktree is still present, each with the
branch, its `dirty`/`merged` git state, and the integration commands for the configured policy.
- **Paused — resumable session** — retained sessions waiting to be resumed, with the resume command.
-- **Active** — items with a live claim (agent processing, human editing, automation).
+- **Active** — items with a live claim. A worker-run claim reports **Worker preparing** while the
+ workspace and launch are being prepared, then the selected agent as **working** after its
+ operating-system process starts; human editing and automation claims retain their own labels.
- **Resume queued** — items marked to be resumed by a continuous worker.
- **Retry scheduled** — retained sessions waiting for their bounded retry time.
- **Handoff queued** — retained workspaces waiting for a due cross-agent continuation by the
@@ -1135,7 +1137,7 @@ wrighty status --json # same groups for scripting
`wrighty provider probe AGENT` to test it immediately without selecting a work item.
- **Local worker processes** — one installation-local heartbeat record per worker invocation,
including CLI-process/web-hosted origin, PID, verified/stale/unknown liveness, lifecycle state,
- current item and agent, cooperative-control version, startup configuration revision, and a
+ current item title and agent, cooperative-control version, startup configuration revision, and a
sanitized invocation summary. The web console orders Running, then Unknown, then Stale workers,
then uses immutable start time and run ID within each group so heartbeat updates never make cards
trade places. Stale rows are visually de-emphasized.
diff --git a/docs/workflows.md b/docs/workflows.md
index 55ddd9ed..f7d4a8f6 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -172,9 +172,7 @@ For Local Markdown, run `wrighty web`, choose **New item**, enter the structured
**Create item**. Creation does not claim the item or start a worker. The resulting card is selected
and the board refreshes.
-[](assets/screenshots/local-markdown-web-ui-item-create.png)
-
-The example above creates an explicitly authorized **Worker queue** item. For ordinary intake,
+To create an explicitly authorized **Worker queue** item, select that status. For ordinary intake,
choose **Todo** and leave **Allow automatic execution** unchecked.
For GitHub, create from the configured Project's `Todo` group or column in a board grouped by the
@@ -383,8 +381,8 @@ worktrees, or choose `--workspace-mode shared` explicitly and accept the collisi
### Web console
-Use **Start worker** on Operations to add a continuous worker hosted by the current web server.
-Select it again to add more. Closing or navigating away from the browser does not stop them;
+Use **Start new worker** in the header's **Workers** overview—or **Start worker** on Operations—to
+add a continuous worker hosted by the current web server. Select it again to add more. Closing or navigating away from the browser does not stop them;
stopping `wrighty web` does. Workspace concurrency behaves exactly as it does for CLI workers:
`current` serializes access and shows extra workers waiting, `worktree` isolates concurrent work,
and `shared` accepts collision risk. For a persistent service, start `wrighty worker` independently
@@ -498,12 +496,7 @@ Wrighty takes no claim, passes no claimant credentials, and leaves any further c
workspace changes to the operator.
The Local Markdown item panel keeps the agent's request, last-run result, retained session and
-workspace, and the next recovery actions together. Select either view for the full-size image.
-
-
-
-
-
+workspace, and the next recovery actions together.
1. Open the item marked **Agent needs attention**.
2. If the work item is already correct and an external problem has been fixed, choose **Queue for
diff --git a/src/Highbyte.Wrighty.Cli/Output/OutputWriter.cs b/src/Highbyte.Wrighty.Cli/Output/OutputWriter.cs
index 2296c0da..87d29968 100644
--- a/src/Highbyte.Wrighty.Cli/Output/OutputWriter.cs
+++ b/src/Highbyte.Wrighty.Cli/Output/OutputWriter.cs
@@ -27,6 +27,7 @@ public sealed class OutputWriter(
TextWriter error,
Func? clock = null)
{
+ private const string AgentFallback = "agent";
private readonly Func now = clock ?? (() => DateTimeOffset.UtcNow);
private static readonly string[] PartialErrorDetailKeys =
@@ -148,7 +149,8 @@ public async Task WriteStatusAsync(
var completed = Group(items, OperationalStatuses.Completed);
var paused = Group(items, OperationalStatuses.PausedSession);
var active = items.Where(value => value.OperationalStatus
- is OperationalStatuses.AgentActive
+ is OperationalStatuses.WorkerPreparing
+ or OperationalStatuses.AgentActive
or OperationalStatuses.HumanEditing
or OperationalStatuses.AutomationActive).ToArray();
var queued = Group(items, OperationalStatuses.Queued);
@@ -1730,16 +1732,18 @@ private static string WorktreeMarker(WorkItemOperationalState value) =>
private string OperationalStatusToken(WorkItemOperationalState value) => value.OperationalStatus switch
{
OperationalStatuses.NeedsAttention => "!attention",
+ OperationalStatuses.WorkerPreparing =>
+ $"preparing:{value.Claim.Agent ?? AgentFallback}",
OperationalStatuses.AgentActive when IsWorkerRunClaim(value) =>
- $"processing:{value.Claim.Agent ?? "agent"}",
- OperationalStatuses.AgentActive => $"claimed:{value.Claim.Agent ?? "agent"}",
- OperationalStatuses.Queued => $"queued:{value.Session?.Agent ?? "agent"}",
+ $"processing:{value.Claim.Agent ?? AgentFallback}",
+ OperationalStatuses.AgentActive => $"claimed:{value.Claim.Agent ?? AgentFallback}",
+ OperationalStatuses.Queued => $"queued:{value.Session?.Agent ?? AgentFallback}",
OperationalStatuses.RetryScheduled => value.Session?.Dispatch is { } dispatch
? $"retry:{dispatch.NotBefore.ToLocalTime():HH:mm}"
: "retry",
OperationalStatuses.HandoffQueued =>
- $"handoff:{value.Session?.Dispatch?.Agent ?? "agent"}",
- OperationalStatuses.PausedSession => $"paused:{value.Session?.Agent ?? "agent"}",
+ $"handoff:{value.Session?.Dispatch?.Agent ?? AgentFallback}",
+ OperationalStatuses.PausedSession => $"paused:{value.Session?.Agent ?? AgentFallback}",
OperationalStatuses.Completed => "completed",
OperationalStatuses.HumanEditing => "human",
OperationalStatuses.AutomationActive => "automation",
@@ -1750,6 +1754,7 @@ OperationalStatuses.AgentActive when IsWorkerRunClaim(value) =>
private string OperationalStatusLabel(WorkItemOperationalState value) => value.OperationalStatus switch
{
OperationalStatuses.NeedsAttention => "Needs attention",
+ OperationalStatuses.WorkerPreparing => "Worker preparing",
OperationalStatuses.AgentActive when IsWorkerRunClaim(value) =>
$"{AgentLabel(value.Claim.Agent) ?? "Agent"} processing",
OperationalStatuses.AgentActive => $"{AgentLabel(value.Claim.Agent) ?? "Agent"} claimed",
@@ -1759,7 +1764,7 @@ OperationalStatuses.AgentActive when IsWorkerRunClaim(value) =>
: "Retry scheduled",
OperationalStatuses.HandoffQueued => value.Session?.Dispatch is { } dispatch
? $"{AgentLabel(dispatch.SessionAgent) ?? "Agent"} → " +
- $"{AgentLabel(dispatch.Agent) ?? "agent"}"
+ $"{AgentLabel(dispatch.Agent) ?? AgentFallback}"
: "Handoff queued",
OperationalStatuses.PausedSession => "Session retained",
OperationalStatuses.Completed => "Completed",
@@ -1892,6 +1897,7 @@ private object OperationalDto(
claimantId = claimView?.ClaimantId,
sessionId = claimView?.SessionId,
workspacePath = claimView?.WorkspacePath,
+ executionPhase = claimView?.ExecutionPhase,
workerRun = IsWorkerRunClaim(value),
leaseRemainingSeconds = LeaseRemainingSeconds(value.Claim),
value.Claim.TakeoverAvailable
diff --git a/src/Highbyte.Wrighty.Core/AgentContext/AgentExecutionContext.cs b/src/Highbyte.Wrighty.Core/AgentContext/AgentExecutionContext.cs
index a9e02f51..d5a48f70 100644
--- a/src/Highbyte.Wrighty.Core/AgentContext/AgentExecutionContext.cs
+++ b/src/Highbyte.Wrighty.Core/AgentContext/AgentExecutionContext.cs
@@ -7,7 +7,8 @@ public sealed record AgentExecutionContext(
string? Warning = null,
ClaimantKind ClaimantKind = ClaimantKind.Unknown,
string? ClaimantId = null,
- string? ClaimToken = null)
+ string? ClaimToken = null,
+ string? ExecutionPhase = null)
{
public ClaimantKind EffectiveClaimantKind => ClaimantKind;
diff --git a/src/Highbyte.Wrighty.Core/Claims/ClaimMarker.cs b/src/Highbyte.Wrighty.Core/Claims/ClaimMarker.cs
index db2b9774..1150e9ea 100644
--- a/src/Highbyte.Wrighty.Core/Claims/ClaimMarker.cs
+++ b/src/Highbyte.Wrighty.Core/Claims/ClaimMarker.cs
@@ -49,7 +49,10 @@ value.EventType is not ("acquired" or "takenOver" or "released" or
Agent = Normalize(value.Agent),
SessionId = NormalizeOpaque(value.SessionId),
WorkspacePath = NormalizeWorkspace(value.WorkspacePath),
- ClaimantKind = ClaimantKinds.ToStorageValue(ClaimantKinds.FromStorageValue(value.ClaimantKind))
+ ClaimantKind = ClaimantKinds.ToStorageValue(ClaimantKinds.FromStorageValue(value.ClaimantKind)),
+ ExecutionPhase = ClaimantKinds.FromStorageValue(value.ClaimantKind) == ClaimantKind.Agent
+ ? ClaimExecutionPhases.Normalize(value.ExecutionPhase)
+ : null
};
return true;
}
diff --git a/src/Highbyte.Wrighty.Core/Claims/ClaimModels.cs b/src/Highbyte.Wrighty.Core/Claims/ClaimModels.cs
index 06d22cd9..e9586dcd 100644
--- a/src/Highbyte.Wrighty.Core/Claims/ClaimModels.cs
+++ b/src/Highbyte.Wrighty.Core/Claims/ClaimModels.cs
@@ -3,6 +3,19 @@
namespace Highbyte.Wrighty.Claims;
+public static class ClaimExecutionPhases
+{
+ public const string Preparing = "preparing";
+ public const string Invoking = "invoking";
+
+ public static string? Normalize(string? value) => value?.Trim().ToLowerInvariant() switch
+ {
+ Preparing => Preparing,
+ Invoking => Invoking,
+ _ => null
+ };
+}
+
[method: System.Text.Json.Serialization.JsonConstructor]
public sealed record ClaimRecord(
int Version,
@@ -17,7 +30,8 @@ public sealed record ClaimRecord(
string? Agent = null,
string? SessionId = null,
string ClaimantKind = "unknown",
- string? WorkspacePath = null);
+ string? WorkspacePath = null,
+ string? ExecutionPhase = null);
public sealed record ClaimEvent(
long CommentId,
@@ -44,7 +58,8 @@ public sealed record ClaimResult(
string? ClaimantId = null,
string? ClaimToken = null,
bool TakeoverAvailable = false,
- string? WorkspacePath = null);
+ string? WorkspacePath = null,
+ string? ExecutionPhase = null);
public enum ClaimOwnershipState
{
@@ -62,7 +77,8 @@ public sealed record ClaimOwnershipResult(
string? SessionId = null,
string ClaimantKind = "unknown",
bool TakeoverAvailable = false,
- string? WorkspacePath = null);
+ string? WorkspacePath = null,
+ string? ExecutionPhase = null);
///
/// The durable outcome of the most recent agent run recorded for a work item. Captured when the
diff --git a/src/Highbyte.Wrighty.Core/Claims/GitHubClaimService.cs b/src/Highbyte.Wrighty.Core/Claims/GitHubClaimService.cs
index f405a31e..9ac71767 100644
--- a/src/Highbyte.Wrighty.Core/Claims/GitHubClaimService.cs
+++ b/src/Highbyte.Wrighty.Core/Claims/GitHubClaimService.cs
@@ -298,7 +298,11 @@ public async Task TakeoverAsync(TrackerConfig config, WorkItemId id
{
Agent = claimantContext.Agent ?? current.Claim.Agent,
SessionId = claimantContext.SessionId ?? current.Claim.SessionId,
- WorkspacePath = current.Claim.WorkspacePath
+ WorkspacePath = current.Claim.WorkspacePath,
+ ExecutionPhase = claimantContext.EffectiveClaimantKind == ClaimantKind.Agent
+ ? ClaimExecutionPhases.Normalize(claimantContext.ExecutionPhase) ??
+ current.Claim.ExecutionPhase
+ : null
};
await CreateAsync(config, issue, claim, cancellationToken);
var winner = await ResolvedAsync(config, issue, id, cancellationToken);
@@ -349,7 +353,11 @@ public async Task RenewAsync(
Agent = claimHandle.Claimant.Agent ?? current.Claim.Agent,
SessionId = sessionId ?? current.Claim.SessionId,
ClaimantKind = ClaimantKinds.ToStorageValue(claimHandle.Claimant.EffectiveClaimantKind),
- WorkspacePath = workspacePath ?? current.Claim.WorkspacePath
+ WorkspacePath = workspacePath ?? current.Claim.WorkspacePath,
+ ExecutionPhase = claimHandle.Claimant.EffectiveClaimantKind == ClaimantKind.Agent
+ ? ClaimExecutionPhases.Normalize(claimHandle.Claimant.ExecutionPhase) ??
+ current.Claim.ExecutionPhase
+ : null
};
await CreateAsync(config, issue, renewed, cancellationToken);
var winner = await ResolvedAsync(config, issue, id, cancellationToken);
@@ -528,7 +536,7 @@ private static ClaimOwnershipResult Ownership(
local ? ClaimOwnershipState.OwnedByCurrent : ClaimOwnershipState.HeldByOther,
claim.InstallationId, claim.ExpiresAt, claim.ClaimantId,
claim.Agent, claim.SessionId, claim.ClaimantKind, local,
- CachedWorkspace(claim, worker, cached));
+ CachedWorkspace(claim, worker, cached), claim.ExecutionPhase);
}
///
@@ -838,7 +846,10 @@ private static ClaimRecord NewEvent(string type, string worker, string claimantI
AgentExecutionContext context, DateTimeOffset now, TrackerConfig config, string? previous) =>
new(3, Guid.NewGuid().ToString("N"), worker, now, now.AddMinutes(config.LeaseMinutes), type,
claimantId, Guid.NewGuid().ToString("N"), previous, context.Agent, context.SessionId,
- ClaimantKinds.ToStorageValue(context.EffectiveClaimantKind));
+ ClaimantKinds.ToStorageValue(context.EffectiveClaimantKind),
+ ExecutionPhase: context.EffectiveClaimantKind == ClaimantKind.Agent
+ ? ClaimExecutionPhases.Normalize(context.ExecutionPhase)
+ : null);
private static ClaimResult Result(ClaimRecord claim, ClaimOutcome outcome, bool takeover) =>
new(outcome, claim.InstallationId, claim.ExpiresAt, claim.EventId,
@@ -847,7 +858,8 @@ outcome is ClaimOutcome.Acquired or ClaimOutcome.AlreadyOwned or ClaimOutcome.Ta
? claim.ClaimToken
: null,
takeover,
- claim.WorkspacePath);
+ claim.WorkspacePath,
+ claim.ExecutionPhase);
private static string ResolveClaimantId(AgentExecutionContext context, bool generate)
{
diff --git a/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.cs b/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.cs
index ddae2ce5..4a0ba196 100644
--- a/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.cs
+++ b/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.cs
@@ -1360,7 +1360,10 @@ public async Task TryClaimAsync(
agentContext.SessionId,
now,
now.AddMinutes(config.LeaseMinutes),
- ClaimantKinds.ToStorageValue(agentContext.EffectiveClaimantKind));
+ ClaimantKinds.ToStorageValue(agentContext.EffectiveClaimantKind),
+ ExecutionPhase: agentContext.EffectiveClaimantKind == ClaimantKind.Agent
+ ? ClaimExecutionPhases.Normalize(agentContext.ExecutionPhase)
+ : null);
state.Claims[document.Id] = claim;
await LocalRuntimeStateStore.SaveUnlockedAsync(paths.Root, state, cancellationToken);
return ClaimResult(claim, ClaimOutcome.Acquired, true);
@@ -1403,7 +1406,12 @@ public async Task TakeoverAsync(
claimantContext.Agent ?? current.Agent,
claimantContext.SessionId ?? current.SessionId, now, now.AddMinutes(config.LeaseMinutes),
ClaimantKinds.ToStorageValue(claimantContext.EffectiveClaimantKind),
- current.WorkspacePath);
+ current.WorkspacePath,
+ current.Branch,
+ claimantContext.EffectiveClaimantKind == ClaimantKind.Agent
+ ? ClaimExecutionPhases.Normalize(claimantContext.ExecutionPhase) ??
+ current.ExecutionPhase
+ : null);
state.Claims[document.Id] = replacement;
await LocalRuntimeStateStore.SaveUnlockedAsync(paths.Root, state, cancellationToken);
return ClaimResult(replacement, ClaimOutcome.TakenOver, true);
@@ -1445,7 +1453,11 @@ public async Task RenewClaimAsync(
SessionId = sessionId ?? current.SessionId,
ClaimantKind = ClaimantKinds.ToStorageValue(claimHandle.Claimant.EffectiveClaimantKind),
WorkspacePath = workspacePath ?? current.WorkspacePath,
- Branch = branch ?? current.Branch
+ Branch = branch ?? current.Branch,
+ ExecutionPhase = claimHandle.Claimant.EffectiveClaimantKind == ClaimantKind.Agent
+ ? ClaimExecutionPhases.Normalize(claimHandle.Claimant.ExecutionPhase) ??
+ current.ExecutionPhase
+ : null
};
state.Claims[document.Id] = renewed;
state.PreserveSession(document.Id, renewed, now);
@@ -1481,7 +1493,8 @@ public async Task GetClaimOwnershipAsync(
current.SessionId,
current.ClaimantKind,
string.Equals(current.InstallationId, worker, StringComparison.Ordinal),
- current.WorkspacePath);
+ current.WorkspacePath,
+ current.ExecutionPhase);
}
public async Task GetAgentSessionAsync(
@@ -2145,7 +2158,8 @@ outcome is ClaimOutcome.Acquired or ClaimOutcome.AlreadyOwned or ClaimOutcome.Ta
? claim.ClaimToken
: null,
takeoverAvailable,
- claim.WorkspacePath);
+ claim.WorkspacePath,
+ claim.ExecutionPhase);
private static string ResolveClaimantId(AgentExecutionContext context, bool generateForAgent)
{
@@ -2252,7 +2266,8 @@ private static WorkItemClaimSummary ClaimSummary(
claim.ClaimantKind,
claim.ClaimantId,
string.Equals(claim.InstallationId, worker, StringComparison.Ordinal),
- claim.WorkspacePath);
+ claim.WorkspacePath,
+ claim.ExecutionPhase);
}
if (claim is not null)
@@ -2266,7 +2281,8 @@ private static WorkItemClaimSummary ClaimSummary(
claim.ClaimantKind,
claim.ClaimantId,
false,
- claim.WorkspacePath);
+ claim.WorkspacePath,
+ claim.ExecutionPhase);
}
if (state.Runtime(documentId) is { } record)
diff --git a/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalRuntimeStateStore.cs b/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalRuntimeStateStore.cs
index ad48b0dc..bfd66949 100644
--- a/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalRuntimeStateStore.cs
+++ b/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalRuntimeStateStore.cs
@@ -15,7 +15,8 @@ internal sealed record LocalClaimRecord(
DateTimeOffset ExpiresAt,
string ClaimantKind,
string? WorkspacePath = null,
- string? Branch = null)
+ string? Branch = null,
+ string? ExecutionPhase = null)
{
[System.Text.Json.Serialization.JsonIgnore]
public bool HasAddress =>
diff --git a/src/Highbyte.Wrighty.Core/Models/WorkItemModels.cs b/src/Highbyte.Wrighty.Core/Models/WorkItemModels.cs
index f79aa584..c61a49f7 100644
--- a/src/Highbyte.Wrighty.Core/Models/WorkItemModels.cs
+++ b/src/Highbyte.Wrighty.Core/Models/WorkItemModels.cs
@@ -200,7 +200,8 @@ public sealed record WorkItemClaimSummary(
string ClaimantKind = "unknown",
string? ClaimantId = null,
bool TakeoverAvailable = false,
- string? WorkspacePath = null)
+ string? WorkspacePath = null,
+ string? ExecutionPhase = null)
{
public static WorkItemClaimSummary FromOwnership(ClaimOwnershipResult ownership) => new(
ownership.State,
@@ -211,7 +212,8 @@ public sealed record WorkItemClaimSummary(
ownership.ClaimantKind,
ownership.ClaimantId,
ownership.TakeoverAvailable,
- ownership.WorkspacePath);
+ ownership.WorkspacePath,
+ ownership.ExecutionPhase);
}
public sealed record DashboardWorkItem(
@@ -387,6 +389,7 @@ public static class OperationalStatuses
public const string Queued = "queued";
public const string RetryScheduled = "retry-scheduled";
public const string HandoffQueued = "handoff-queued";
+ public const string WorkerPreparing = "worker-preparing";
public const string AgentActive = "agent-active";
public const string HumanEditing = "human-editing";
public const string AutomationActive = "automation-active";
@@ -438,7 +441,11 @@ public static string Resolve(
if (claim.State != ClaimOwnershipState.Unclaimed)
{
- return ClaimantKinds.FromStorageValue(claim.ClaimantKind) switch
+ var claimantKind = ClaimantKinds.FromStorageValue(claim.ClaimantKind);
+ if (claimantKind == ClaimantKind.Agent &&
+ claim.ExecutionPhase == ClaimExecutionPhases.Preparing)
+ return WorkerPreparing;
+ return claimantKind switch
{
ClaimantKind.Agent => AgentActive,
ClaimantKind.Human => HumanEditing,
diff --git a/src/Highbyte.Wrighty.Core/Workers/AgentProcessRunner.cs b/src/Highbyte.Wrighty.Core/Workers/AgentProcessRunner.cs
index 97c01138..8a420c48 100644
--- a/src/Highbyte.Wrighty.Core/Workers/AgentProcessRunner.cs
+++ b/src/Highbyte.Wrighty.Core/Workers/AgentProcessRunner.cs
@@ -5,6 +5,10 @@
namespace Highbyte.Wrighty.Workers;
+public sealed record AgentProcessCallbacks(
+ Func? ProcessStarted = null,
+ Func? SessionStarted = null);
+
public interface IAgentProcessRunner
{
Task RunAsync(
@@ -15,23 +19,59 @@ Task RunAsync(
Func? sessionStarted,
bool killOnCancellation,
CancellationToken cancellationToken);
+
+ Task RunWithCallbacksAsync(
+ AgentInvocation invocation,
+ IAgentAdapter adapter,
+ TimeSpan timeout,
+ IReadOnlyDictionary grantEnvironment,
+ AgentProcessCallbacks callbacks,
+ bool killOnCancellation,
+ CancellationToken cancellationToken) =>
+ RunAsync(invocation, adapter, timeout, grantEnvironment, callbacks.SessionStarted,
+ killOnCancellation, cancellationToken);
}
public sealed class AgentProcessRunner(IExecutableResolver executables) : IAgentProcessRunner
{
- public async Task RunAsync(
+ public Task RunAsync(
AgentInvocation invocation,
IAgentAdapter adapter,
TimeSpan timeout,
IReadOnlyDictionary grantEnvironment,
Func? sessionStarted,
bool killOnCancellation,
+ CancellationToken cancellationToken) =>
+ RunWithCallbacksAsync(invocation, adapter, timeout, grantEnvironment,
+ new AgentProcessCallbacks(SessionStarted: sessionStarted),
+ killOnCancellation, cancellationToken);
+
+ public async Task RunWithCallbacksAsync(
+ AgentInvocation invocation,
+ IAgentAdapter adapter,
+ TimeSpan timeout,
+ IReadOnlyDictionary grantEnvironment,
+ AgentProcessCallbacks callbacks,
+ bool killOnCancellation,
CancellationToken cancellationToken)
{
if (timeout <= TimeSpan.Zero)
throw new TrackerException("ARGUMENT_INVALID", "--item-timeout must be positive.", 2);
using var process = StartProcess(invocation, grantEnvironment);
+ if (callbacks.ProcessStarted is not null)
+ {
+ try
+ {
+ await callbacks.ProcessStarted(cancellationToken);
+ }
+ catch
+ {
+ Kill(process);
+ await process.WaitForExitAsync(CancellationToken.None);
+ throw;
+ }
+ }
// The prompt goes here when there is one: an approved context on the command line would be
// readable by every process on the machine, and would appear in the argument list worker
@@ -54,7 +94,7 @@ public async Task RunAsync(
};
var capture = new SessionCapture();
var stdoutTask = CaptureStdoutAsync(
- process, writer, adapter, capture, sessionStarted, cancellationToken);
+ process, writer, adapter, capture, callbacks.SessionStarted, cancellationToken);
using var timeoutCts = new CancellationTokenSource(timeout);
using var combined = CancellationTokenSource.CreateLinkedTokenSource(
diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerInstances.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerInstances.cs
index 1d0d467c..47111059 100644
--- a/src/Highbyte.Wrighty.Core/Workers/WorkerInstances.cs
+++ b/src/Highbyte.Wrighty.Core/Workers/WorkerInstances.cs
@@ -14,7 +14,8 @@ public enum WorkerInstanceState
Stopping,
Draining,
StoppingNow,
- Finalizing
+ Finalizing,
+ PreparingItem
}
public enum WorkerHostKind
@@ -51,7 +52,8 @@ public sealed record WorkerInstance(
WorkerHostKind HostKind = WorkerHostKind.CliProcess,
string? CurrentAgent = null,
int? ControlProtocolVersion = null,
- IReadOnlyList? SupportedStopModes = null);
+ IReadOnlyList? SupportedStopModes = null,
+ string? CurrentItemTitle = null);
public sealed record WorkerInstanceStatus(
WorkerInstance Instance,
@@ -93,6 +95,14 @@ Task UpdateAsync(
CancellationToken cancellationToken) =>
UpdateAsync(currentItemId, state, cancellationToken);
+ Task UpdateAsync(
+ string? currentItemId,
+ string? currentItemTitle,
+ string? currentAgent,
+ WorkerInstanceState state,
+ CancellationToken cancellationToken) =>
+ UpdateAsync(currentItemId, currentAgent, state, cancellationToken);
+
Task ReadStopRequestAsync(CancellationToken cancellationToken) =>
Task.FromResult(null);
@@ -604,15 +614,36 @@ public async Task UpdateAsync(
string? currentAgent,
WorkerInstanceState state,
CancellationToken cancellationToken)
+ => await UpdateAsync(
+ currentItemId,
+ currentItemTitle: null,
+ currentAgent,
+ state,
+ cancellationToken);
+
+ public async Task UpdateAsync(
+ string? currentItemId,
+ string? currentItemTitle,
+ string? currentAgent,
+ WorkerInstanceState state,
+ CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken);
try
{
if (disposed)
return;
+ string? nextItemTitle = null;
+ if (currentItemId is not null)
+ {
+ nextItemTitle = currentItemTitle;
+ if (string.Equals(current.CurrentItemId, currentItemId, StringComparison.Ordinal))
+ nextItemTitle ??= current.CurrentItemTitle;
+ }
current = current with
{
CurrentItemId = currentItemId,
+ CurrentItemTitle = nextItemTitle,
CurrentAgent = currentAgent,
State = state,
LastHeartbeatAt = clock()
diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerModels.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerModels.cs
index e0ab7aa1..d9781a18 100644
--- a/src/Highbyte.Wrighty.Core/Workers/WorkerModels.cs
+++ b/src/Highbyte.Wrighty.Core/Workers/WorkerModels.cs
@@ -97,7 +97,10 @@ public sealed record WorkerEvent(
ProviderCapacity? ProviderCapacity = null,
// The effective spawned-agent permission posture for this run, so a machine consumer can see
// what the agent was actually granted rather than inferring it from the vendor argv.
- AgentPermissions? Permissions = null);
+ AgentPermissions? Permissions = null,
+ // Local worker-process presentation only. It lets the web console identify the active item
+ // without polling the tracker while its worker drawer is open.
+ string? ItemTitle = null);
public enum WorkerEventSemantic
{
@@ -114,7 +117,7 @@ public static class WorkerEventClassifier
{
"check" or "finished" or "workspace-removed" or "requirements-assessment-ready" =>
WorkerEventSemantic.Success,
- "info" or "ready" or "started" or "resumed" or "session" or "dry-run" or
+ "info" or "ready" or "preparing" or "started" or "resumed" or "session" or "dry-run" or
"retry-due" or "retry-started" or "handoff-due" or "handoff-directed" or
"handoff-started" or
"provider-probe-started" or "provider-available" or
diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerRunHost.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerRunHost.cs
index 193157a4..d297c3c8 100644
--- a/src/Highbyte.Wrighty.Core/Workers/WorkerRunHost.cs
+++ b/src/Highbyte.Wrighty.Core/Workers/WorkerRunHost.cs
@@ -29,6 +29,7 @@ public sealed record WorkerRunCallbacks(
internal sealed record WorkerInstanceEventState(
string? ItemId,
+ string? ItemTitle,
string? Agent,
WorkerInstanceState State);
@@ -38,17 +39,23 @@ internal static class WorkerInstanceEventProjection
WorkerEvent value,
WorkerRunControl control)
{
- var running = value.Type is "started" or "resumed" or "running" or "session";
+ var preparing = value.Type == "preparing";
+ var running = value.Type is "started" or "resumed" or "retry-started" or
+ "handoff-started" or "requirements-assessment-started" or "running" or "session";
var terminal = value.Type is "finished" or "needs-attention" or "failed" or "fenced"
or "timed-out" or "rejected" or "retry-scheduled" or "interrupted";
- if (running && value.ItemId is not null)
+ if ((preparing || running) && value.ItemId is not null)
{
+ var state = WorkerInstanceState.RunningItem;
+ if (control.IntakeClosed)
+ state = WorkerInstanceState.Draining;
+ else if (preparing)
+ state = WorkerInstanceState.PreparingItem;
return new WorkerInstanceEventState(
value.ItemId,
+ value.ItemTitle,
value.Agent,
- control.IntakeClosed
- ? WorkerInstanceState.Draining
- : WorkerInstanceState.RunningItem);
+ state);
}
if (!terminal)
return null;
@@ -58,7 +65,7 @@ internal static class WorkerInstanceEventProjection
terminalState = WorkerInstanceState.Finalizing;
else if (control.IntakeClosed)
terminalState = WorkerInstanceState.Draining;
- return new WorkerInstanceEventState(null, null, terminalState);
+ return new WorkerInstanceEventState(null, null, null, terminalState);
}
}
@@ -284,10 +291,10 @@ await TryUpdateAsync(
registration,
null,
null,
+ null,
WorkerInstanceState.Finalizing,
warningState,
- callbacks.Warn,
- CancellationToken.None);
+ callbacks.Warn);
}
await pollingStop.CancelAsync();
try
@@ -394,11 +401,11 @@ private static async Task ProjectEventAsync(
await TryUpdateAsync(
registration,
projected.ItemId,
+ projected.ItemTitle,
projected.Agent,
projected.State,
warningState,
- warn,
- CancellationToken.None);
+ warn);
}
await emit(value);
}
@@ -406,15 +413,20 @@ await TryUpdateAsync(
private static async Task TryUpdateAsync(
IWorkerInstanceRegistration registration,
string? itemId,
+ string? itemTitle,
string? agent,
WorkerInstanceState state,
RegistryWarningState warningState,
- Func? warn,
- CancellationToken cancellationToken)
+ Func? warn)
{
try
{
- await registration.UpdateAsync(itemId, agent, state, cancellationToken);
+ await registration.UpdateAsync(
+ itemId,
+ itemTitle,
+ agent,
+ state,
+ CancellationToken.None);
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException)
diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs
index 4fec7bb9..11a5fb13 100644
--- a/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs
+++ b/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs
@@ -45,6 +45,7 @@ public sealed class WorkerService(
// The lifecycle event name, distinct from the WorkerDispatchStates value of the same text: one
// is the emitted event, the other the published item state.
private const string NeedsAttentionEvent = "needs-attention";
+ private const string PreparingEvent = "preparing";
/// Emitted whenever a claim turned out to be stale, expired, or owned elsewhere.
private const string FencedEvent = "fenced";
@@ -629,7 +630,8 @@ private async Task RunFreshCandidateAsync(
null,
AgentContextSource.ExplicitOption,
ClaimantKind: kind,
- ClaimantId: claimantId);
+ ClaimantId: claimantId,
+ ExecutionPhase: ClaimExecutionPhases.Preparing);
var agentSettings = await LoadUserSettingsAsync(cancellationToken);
var providerStates = await ProviderStatesAsync(config, cancellationToken);
await using var workspaceLease = options.WorkspaceMode == WorkspaceMode.Current
@@ -683,6 +685,11 @@ private async Task RunFreshCandidateAsync(
if (selectedAgent is null || selectedDetail is null)
throw new TrackerException("AGENT_REQUIRED",
"An eligible item did not resolve to a supported agent.", 2);
+ await emit(new WorkerEvent(
+ PreparingEvent,
+ selectedDetail.Id.Value,
+ selectedAgent,
+ ItemTitle: selectedDetail.Title));
return await ProcessAsync(
config, options, repositoryPath, picked.Claim, selectedDetail,
selectedAgent, claimantId, kind, emit, cancellationToken);
@@ -1188,8 +1195,14 @@ await emit(new WorkerEvent("dry-run", detail.Id.Value, agentName, workspace.Path
null,
AgentContextSource.ExplicitOption,
ClaimantKind: kind,
- ClaimantId: claimantId);
+ ClaimantId: claimantId,
+ ExecutionPhase: ClaimExecutionPhases.Preparing);
var claim = await tracker.ClaimAsync(config, id, context, cancellationToken);
+ await emit(new WorkerEvent(
+ PreparingEvent,
+ detail.Id.Value,
+ agentName,
+ ItemTitle: detail.Title));
var targetStatus = options.ToStatus ?? config.DefaultPickTo;
if (!string.IsNullOrWhiteSpace(targetStatus) &&
@@ -1263,9 +1276,15 @@ await emit(new WorkerEvent("dry-run", id.Value, agentName, workspace.Path,
AgentContextSource.ExplicitOption,
ClaimantKind: ClaimantKind.Agent,
ClaimantId: claimantId,
- ClaimToken: currentClaimToken);
+ ClaimToken: currentClaimToken,
+ ExecutionPhase: ClaimExecutionPhases.Preparing);
var claim = await tracker.TakeoverAsync(config, id, takeoverContext,
currentClaimToken, cancellationToken);
+ await emit(new WorkerEvent(
+ PreparingEvent,
+ detail.Id.Value,
+ agentName,
+ ItemTitle: detail.Title));
var claimContext = takeoverContext with { ClaimToken = claim.ClaimToken };
var grant = new ClaimHandle(claimContext, claim.ClaimToken);
detail = await ClearDispatchStateAsync(
@@ -1358,9 +1377,15 @@ await emit(new WorkerEvent(
session.SessionId,
AgentContextSource.ExplicitOption,
ClaimantKind: ClaimantKind.Agent,
- ClaimantId: claimantId);
+ ClaimantId: claimantId,
+ ExecutionPhase: ClaimExecutionPhases.Preparing);
var claim = await tracker.ClaimAsync(
config, detail.Id, context, cancellationToken);
+ await emit(new WorkerEvent(
+ PreparingEvent,
+ detail.Id.Value,
+ agentName,
+ ItemTitle: detail.Title));
var claimContext = context with { ClaimToken = claim.ClaimToken };
var grant = new ClaimHandle(claimContext, claim.ClaimToken);
@@ -1881,6 +1906,11 @@ private async Task ExecuteHandoffAsync(
var (claimantId, claimContext, grant, handle) = await AcquireHandoffClaimAsync(
launch, adapter, cancellationToken);
+ await emit(new WorkerEvent(
+ PreparingEvent,
+ detail.Id.Value,
+ targetAgent,
+ ItemTitle: detail.Title));
var revalidated = await tracker.GetAsync(config, detail.Id, cancellationToken);
var postClaimRequest = new LaunchPreflightRequest(
@@ -2031,7 +2061,8 @@ await emit(new WorkerEvent(
null,
AgentContextSource.ExplicitOption,
ClaimantKind: ClaimantKind.Agent,
- ClaimantId: claimantId);
+ ClaimantId: claimantId,
+ ExecutionPhase: ClaimExecutionPhases.Preparing);
var claim = await tracker.ClaimAsync(
launch.Config, launch.Detail.Id, context, cancellationToken);
var claimGeneration = claim.ClaimToken
@@ -2382,7 +2413,8 @@ private static (SessionHandle Handle, AgentExecutionContext Context, ClaimHandle
AgentContextSource.ExplicitOption,
ClaimantKind: kind,
ClaimantId: claimantId,
- ClaimToken: claimToken);
+ ClaimToken: claimToken,
+ ExecutionPhase: ClaimExecutionPhases.Preparing);
return (handle, context, new ClaimHandle(context, claimToken));
}
@@ -2626,6 +2658,22 @@ private async Task RunClaimedAsync(
int recoveryAttempt = 0,
DispatchInfo? recoveryDispatch = null)
{
+ // Lease renewals straddle process startup. Leaving their phase unspecified preserves
+ // whichever durable phase is current: preparing before Process.Start, invoking after the
+ // process-start callback records that transition.
+ var leaseGrant = run.Grant with
+ {
+ Claimant = run.Grant.Claimant with { ExecutionPhase = null }
+ };
+ var invokingContext = run.ClaimContext with
+ {
+ ExecutionPhase = ClaimExecutionPhases.Invoking
+ };
+ run = run with
+ {
+ ClaimContext = invokingContext,
+ Grant = run.Grant with { Claimant = invokingContext }
+ };
var (config, options, detail, agentName, _,
claimContext, grant, workspace, invocation, _, _, invocationKind,
expectedSessionId, _, _, assessment) = run;
@@ -2642,7 +2690,7 @@ private async Task RunClaimedAsync(
run,
runControl,
observation);
- var leaseTask = KeepAliveAsync(config, detail.Id, grant, workspace.Path,
+ var leaseTask = KeepAliveAsync(config, detail.Id, leaseGrant, workspace.Path,
startedAt, deadline, initialClaimExpiresAt,
options, emit, runCts, leaseCts.Token, () => fenceState.Fenced = true,
() => runCts.Cancel());
@@ -2740,29 +2788,64 @@ await emit(new WorkerEvent(FencedEvent, detail.Id.Value, agentName,
new EndedRun(detail, agentName, grant, workspace, result, sessionId),
adapter, emit, cancellationToken, recoveryAttempt);
- Task RunAgentProcessAsync() => processes.RunAsync(
+ Task RunAgentProcessAsync() => processes.RunWithCallbacksAsync(
invocation,
adapter,
options.ItemTimeout,
environment,
- (sessionId, token) =>
- {
- observation.ObservedSessionId = sessionId;
- if (invocationKind == AgentInvocationKind.Resume &&
- expectedSessionId is not null &&
- !SessionIdsEqual(expectedSessionId, sessionId))
+ new AgentProcessCallbacks(
+ token => RecordAgentProcessStartedAsync(
+ config, detail, agentName, workspace, grant, token),
+ (sessionId, token) =>
{
- observation.UnexpectedSessionId = sessionId;
- return Task.CompletedTask;
- }
- return RecordSessionAsync(
- config, detail, agentName, workspace, grant, sessionId, token,
- fenceState, runCts, emit, run.Selection);
- },
+ observation.ObservedSessionId = sessionId;
+ if (invocationKind == AgentInvocationKind.Resume &&
+ expectedSessionId is not null &&
+ !SessionIdsEqual(expectedSessionId, sessionId))
+ {
+ observation.UnexpectedSessionId = sessionId;
+ return Task.CompletedTask;
+ }
+ return RecordSessionAsync(
+ config, detail, agentName, workspace, grant, sessionId, token,
+ fenceState, runCts, emit, run.Selection);
+ }),
options.OnFenced == FencedAction.Kill,
runCts.Token);
}
+ private async Task RecordAgentProcessStartedAsync(
+ TrackerConfig config,
+ WorkItemDetail detail,
+ string agentName,
+ Workspace workspace,
+ ClaimHandle grant,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await tracker.RenewClaimAsync(
+ config, detail.Id, grant, workspace.Path, grant.Claimant.SessionId,
+ workspace.Branch, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ var detailCode = exception is TrackerException trackerException
+ ? $" ({trackerException.Code})"
+ : string.Empty;
+ throw new TrackerException(
+ "AGENT_START_FAILED",
+ $"{AgentDisplayName(agentName)} started, but Wrighty could not record its active " +
+ $"invocation for '{detail.Id}'{detailCode}.",
+ 7,
+ innerException: exception);
+ }
+ }
+
private IDisposable? PrepareInterruptionJournal(
ClaimedRun run,
WorkerRunControl? runControl,
diff --git a/src/Highbyte.Wrighty.Web/Assets/app.js b/src/Highbyte.Wrighty.Web/Assets/app.js
index c38e21d2..cb52e316 100644
--- a/src/Highbyte.Wrighty.Web/Assets/app.js
+++ b/src/Highbyte.Wrighty.Web/Assets/app.js
@@ -29,6 +29,7 @@ import {
toggleSortDirection,
dismissBoardFilterMenu,
dismissHeaderPopovers,
+ toggleHeaderPopover,
syncBoardFilterIndicator,
clearBoardFilters,
resetBoardView,
@@ -67,6 +68,7 @@ const boardFilterMenu = document.querySelector("#board-filter-menu");
let boardRevision = null;
let agentsRevision = null;
let workerSummaryRevision = null;
+let workerOverviewRevision = null;
let lastOpenedItem = null;
let authenticationReadyDispatched = false;
let boardControlFocus = null;
@@ -77,7 +79,10 @@ let hostedLogViews = [];
let agentsMenuOpen = false;
let agentDetailsOpen = null;
const agentSkillScopes = new Map();
-let workerProcessesRevealPending = false;
+let workerMenuOpen = false;
+let workerProcessesRevealPending = null;
+let focusedWorkerRunId = null;
+let focusedWorkerTimer = null;
syncBoardFilterIndicator(boardFilters, boardFilterMenu);
@@ -100,14 +105,26 @@ function refreshAgents() {
function refreshWorkerSummary() {
const workerSummary = document.querySelector("#worker-summary-region");
if (workerSummary && document.visibilityState === "visible" &&
+ !workerSummary.querySelector(".worker-menu[open]") &&
!workerSummary.matches(".htmx-request")) {
- workerSummary.dispatchEvent(new CustomEvent("wrighty:refresh"));
+ workerSummary.dispatchEvent(new CustomEvent("wrighty:worker-summary-refresh"));
}
}
+function refreshWorkerOverview() {
+ const menu = document.querySelector("#worker-summary-region .worker-menu");
+ const content = document.querySelector("#worker-overview-content");
+ const requestInFlight = content?.matches?.(".htmx-request") ||
+ content?.querySelector?.(".htmx-request");
+ if (!menu?.open || !content || document.visibilityState !== "visible" || requestInFlight)
+ return;
+ content.dispatchEvent(new CustomEvent("wrighty:worker-overview-refresh"));
+}
+
function refreshDashboard() {
refreshBoard();
refreshWorkerSummary();
+ refreshWorkerOverview();
refreshAgents();
refreshVisibleOperations(document);
}
@@ -127,6 +144,7 @@ document.addEventListener("wrighty:refresh", () => {
boardRevision = null;
agentsRevision = null;
workerSummaryRevision = null;
+ workerOverviewRevision = null;
refreshDashboard();
});
@@ -191,8 +209,10 @@ function visibleCountDescription(count, total, archived, filtered) {
function dispatchAuthenticationReady() {
if (authenticationReadyDispatched || (tokenAuthenticationRequired && !token)) return;
+ const htmx = globalThis.htmx;
+ if (!htmx) return;
authenticationReadyDispatched = true;
- readyPageRegions(document, globalThis.htmx);
+ readyPageRegions(document, htmx);
}
const contextPanel = createContextPanelController({
@@ -230,17 +250,39 @@ function selectTab(tab) {
}
function revealPendingWorkerProcesses() {
- if (!workerProcessesRevealPending || !revealWorkerProcesses(document)) return false;
- workerProcessesRevealPending = false;
+ if (workerProcessesRevealPending === null) return false;
+ const runId = workerProcessesRevealPending || null;
+ if (!revealWorkerProcesses(document, runId)) return false;
+ workerProcessesRevealPending = null;
return true;
}
-function openWorkerProcesses() {
+function focusWorkerForDetails(runId) {
+ focusedWorkerRunId = runId;
+ clearTimeout(focusedWorkerTimer);
+ if (!runId) return;
+ document.getElementById(`worker-instance-${runId}`)
+ ?.classList.add("worker-row-focused");
+ focusedWorkerTimer = setTimeout(() => {
+ document.getElementById(`worker-instance-${runId}`)
+ ?.classList.remove("worker-row-focused");
+ if (focusedWorkerRunId === runId) focusedWorkerRunId = null;
+ }, 6000);
+}
+
+function closeWorkerOverview() {
+ const menu = document.querySelector("#worker-summary-region .worker-menu");
+ if (menu?.open) menu.open = false;
+}
+
+function openWorkerProcesses(runId = null) {
const operationsTab = document.querySelector("#tab-operations");
if (!operationsTab) return;
selectTabWithSettingsGuard(operationsTab, {
afterSelect() {
- workerProcessesRevealPending = true;
+ closeWorkerOverview();
+ focusWorkerForDetails(runId);
+ workerProcessesRevealPending = runId || "";
requestAnimationFrame(revealPendingWorkerProcesses);
}
});
@@ -382,6 +424,10 @@ document.addEventListener("htmx:configRequest", event => {
if (workerSummaryRevision && url.includes("handler=WorkerSummary")) {
event.detail.headers["If-None-Match"] = `"${workerSummaryRevision}"`;
}
+ if (workerOverviewRevision && url.includes("handler=WorkerOverview") &&
+ document.querySelector("#worker-overview-content [data-worker-overview-revision]")) {
+ event.detail.headers["If-None-Match"] = `"${workerOverviewRevision}"`;
+ }
});
document.addEventListener("htmx:beforeRequest", event => {
@@ -396,6 +442,11 @@ document.addEventListener("htmx:beforeRequest", event => {
agentSkillScopes.set(agentDetailsOpen, selectedScope);
}
}
+ const workerSummary = event.detail.target?.closest?.("#worker-summary-region") ||
+ event.target.closest?.("#worker-summary-region");
+ if (workerSummary) {
+ workerMenuOpen = Boolean(workerSummary.querySelector(".worker-menu")?.open);
+ }
const card = event.target.closest?.(".card");
if (card) lastOpenedItem = card.dataset.itemId;
if (event.target.closest?.("[data-board-bulk-action]"))
@@ -477,15 +528,35 @@ function restoreWorkerSummaryAfterSwap(target) {
const workerSummary =
target.closest?.("#worker-summary-region") ||
document.querySelector("#worker-summary-region");
- if (workerSummary?.dataset.revision)
+ if (workerSummary?.dataset.revision) {
workerSummaryRevision = workerSummary.dataset.revision;
+ const menu = workerSummary.querySelector(".worker-menu");
+ if (menu && workerMenuOpen) {
+ menu.open = true;
+ menu.querySelector("#worker-overview-content")
+ ?.dispatchEvent(new CustomEvent("wrighty:worker-overview-open"));
+ }
+ }
+}
+
+function restoreWorkerOverviewAfterSwap(target) {
+ const overview = target.closest?.("#worker-overview-content") ||
+ document.querySelector("#worker-overview-content");
+ const revision = overview?.querySelector("[data-worker-overview-revision]")
+ ?.dataset.workerOverviewRevision;
+ if (revision) workerOverviewRevision = revision;
}
function restoreOperationsAfterSwap(target) {
if (target.closest?.("#operations-content")) {
restoreOperationsControlFocus(document, operationsControlFocus);
operationsControlFocus = null;
- if (workerProcessesRevealPending) requestAnimationFrame(revealPendingWorkerProcesses);
+ if (focusedWorkerRunId) {
+ document.getElementById(`worker-instance-${focusedWorkerRunId}`)
+ ?.classList.add("worker-row-focused");
+ }
+ if (workerProcessesRevealPending !== null)
+ requestAnimationFrame(revealPendingWorkerProcesses);
}
if (target.classList?.contains("hosted-worker-log") || target.id === "operations-content") {
// outerHTML swaps leave detail.target pointing at the detached old node. Resolve against the
@@ -501,6 +572,7 @@ document.addEventListener("htmx:afterSwap", event => {
restoreBoardAfterSwap(target);
restoreAgentsAfterSwap(target);
restoreWorkerSummaryAfterSwap(target);
+ restoreWorkerOverviewAfterSwap(target);
restoreOperationsAfterSwap(target);
const heading = target.querySelector?.(".detail h2");
@@ -742,6 +814,12 @@ function handleGeneralClick(target) {
handleLaunchDialogClick(target);
+ const workerDetails = target.closest("[data-worker-details-run-id]");
+ if (workerDetails) {
+ openWorkerProcesses(workerDetails.dataset.workerDetailsRunId);
+ return;
+ }
+
if (target.closest("[data-open-worker-processes]")) {
openWorkerProcesses();
return;
@@ -795,7 +873,17 @@ document.addEventListener("change", event => {
document.addEventListener("click", event => {
closeTokenPickerPopovers(document, event.target);
dismissWorkspaceModeHelp(document, event.target);
- dismissHeaderPopovers(document, event.target);
+ const headerToggle = toggleHeaderPopover(document, event.target);
+ if (headerToggle) {
+ event.preventDefault();
+ agentsMenuOpen = Boolean(document.querySelector("#agents-region .agents-menu")?.open);
+ workerMenuOpen = Boolean(
+ document.querySelector("#worker-summary-region .worker-menu")?.open);
+ } else if (dismissHeaderPopovers(document, event.target) > 0) {
+ agentsMenuOpen = Boolean(document.querySelector("#agents-region .agents-menu")?.open);
+ workerMenuOpen = Boolean(
+ document.querySelector("#worker-summary-region .worker-menu")?.open);
+ }
const filterClose = event.target.closest?.("[data-close-board-filters]");
if (dismissBoardFilterMenu(boardFilterMenu, event.target) && filterClose) return;
if (handleBoardSortClick(event.target)) return;
@@ -806,6 +894,21 @@ document.addEventListener("click", event => {
handleGeneralClick(event.target);
});
+document.addEventListener("toggle", event => {
+ const agentsMenu = event.target.closest?.("#agents-region .agents-menu");
+ if (agentsMenu) {
+ agentsMenuOpen = agentsMenu.open;
+ return;
+ }
+ const menu = event.target.closest?.("#worker-summary-region .worker-menu");
+ if (!menu) return;
+ workerMenuOpen = menu.open;
+ if (menu.open) {
+ menu.querySelector("#worker-overview-content")
+ ?.dispatchEvent(new CustomEvent("wrighty:worker-overview-open"));
+ }
+}, true);
+
window.addEventListener("resize", () => refreshExpandableValues());
function handleSearchKeydown(event) {
diff --git a/src/Highbyte.Wrighty.Web/Assets/board-controls.mjs b/src/Highbyte.Wrighty.Web/Assets/board-controls.mjs
index 8cf6a290..dc5209fc 100644
--- a/src/Highbyte.Wrighty.Web/Assets/board-controls.mjs
+++ b/src/Highbyte.Wrighty.Web/Assets/board-controls.mjs
@@ -90,16 +90,40 @@ export function dismissHeaderPopovers(doc, target) {
// A fast HTMX response can replace a menu before the originating click bubbles here. The new
// menu cannot contain the now-detached target, but target.closest() still identifies which kind
// of menu the click came from. Keep that replacement open while closing other menu types.
- const sourceMenu = target.closest?.(".agents-menu");
+ const sourceMenu = target.closest?.(".agents-menu, .worker-menu");
+ let sourceMenuClass = null;
+ if (sourceMenu?.classList?.contains("agents-menu")) {
+ sourceMenuClass = "agents-menu";
+ } else if (sourceMenu?.classList?.contains("worker-menu")) {
+ sourceMenuClass = "worker-menu";
+ }
let closed = 0;
- doc.querySelectorAll(".agents-menu[open]").forEach(menu => {
- if (sourceMenu || menu.contains(target)) return;
+ doc.querySelectorAll(".agents-menu[open], .worker-menu[open]").forEach(menu => {
+ if (menu === sourceMenu || menu.contains(target) ||
+ (sourceMenuClass && menu.classList?.contains(sourceMenuClass))) return;
menu.open = false;
closed += 1;
});
return closed;
}
+export function toggleHeaderPopover(doc, target) {
+ if (!doc?.querySelectorAll || !target) return null;
+ const summary = target.closest?.(".agents-menu > summary, .worker-menu > summary");
+ const menu = summary?.closest?.(".agents-menu, .worker-menu");
+ if (!menu) return null;
+
+ const opening = !menu.open;
+ doc.querySelectorAll(".agents-menu[open], .worker-menu[open]").forEach(candidate => {
+ if (candidate !== menu) candidate.open = false;
+ });
+ menu.open = opening;
+ return {
+ kind: menu.classList?.contains("agents-menu") ? "agents" : "workers",
+ open: opening
+ };
+}
+
const structuredBoardFilterNames = new Set([
"claimKind", "agent", "priority", "claimState", "updatedWithin"
]);
diff --git a/src/Highbyte.Wrighty.Web/Assets/page-regions.mjs b/src/Highbyte.Wrighty.Web/Assets/page-regions.mjs
index c92134fe..8dcff02d 100644
--- a/src/Highbyte.Wrighty.Web/Assets/page-regions.mjs
+++ b/src/Highbyte.Wrighty.Web/Assets/page-regions.mjs
@@ -21,11 +21,15 @@ export const readyRegionSelectors = [
* placeholder with its request never sent. Anything missing here is a page variant, not an error.
*/
export function readyPageRegions(doc, htmx) {
+ // A ready event sent before htmx has processed the region is lost forever. The module and the
+ // deferred htmx script normally initialize in the expected order, but a cold or cached load can
+ // reverse them. Wait for the later htmx:load callback instead of consuming the one-time startup.
+ if (!htmx) return [];
const regions = readyRegionSelectors
.map((selector) => doc.querySelector(selector))
.filter((region) => region !== null && region !== undefined);
for (const region of regions) {
- htmx?.process(region);
+ htmx.process(region);
region.dispatchEvent(new CustomEvent("wrighty:ready"));
}
return regions;
@@ -50,17 +54,18 @@ export function refreshVisibleOperations(doc) {
}
/**
- * Polls the Agents inventory while capacity probes run, but not while a short mutation is replacing
- * the same controls. Probe requests intentionally remain visible through read-only polling so the
- * operator sees each acquired probe and its warning state before the vendor call completes.
+ * Polls the Agents inventory while capacity probes run, but not while the popover is open or a
+ * short mutation is replacing the same controls. Direct actions still replace the open inventory;
+ * only background polling pauses, avoiding a visible close/reopen flicker.
*/
export function refreshAgentsInventory(doc) {
const agents = doc.querySelector("#agents-region");
+ const menuOpen = agents?.querySelector?.(".agents-menu[open]");
const blockingRequest = agents?.matches?.(".htmx-request") ||
agents?.querySelector?.(".htmx-request:not([data-agent-probe-request])");
- if (!agents || doc.visibilityState !== "visible" || blockingRequest) return false;
+ if (!agents || doc.visibilityState !== "visible" || menuOpen || blockingRequest) return false;
- agents.dispatchEvent(new CustomEvent("wrighty:refresh"));
+ agents.dispatchEvent(new CustomEvent("wrighty:agents-refresh"));
return true;
}
@@ -71,10 +76,13 @@ export function refreshAgentsInventory(doc) {
* after each replacement, which also pulled the viewport back after the user had scrolled away.
* The page tab is stable across swaps and remains the appropriate keyboard focus destination.
*/
-export function revealWorkerProcesses(doc) {
+export function revealWorkerProcesses(doc, runId = null) {
doc.querySelector("#tab-operations")?.focus?.({ preventScroll: true });
- const workerProcesses = doc.querySelector("#worker-processes");
+ const workerProcesses = runId
+ ? doc.getElementById?.(`worker-instance-${runId}`)
+ : doc.querySelector("#worker-processes");
if (!workerProcesses) return false;
+ if (runId) workerProcesses.classList?.add?.("worker-row-focused");
workerProcesses.scrollIntoView?.({ block: "start", behavior: "auto" });
return true;
}
diff --git a/src/Highbyte.Wrighty.Web/Assets/wrighty.css b/src/Highbyte.Wrighty.Web/Assets/wrighty.css
index 23cf9931..08bf48a6 100644
--- a/src/Highbyte.Wrighty.Web/Assets/wrighty.css
+++ b/src/Highbyte.Wrighty.Web/Assets/wrighty.css
@@ -56,7 +56,9 @@ button.danger { color: var(--danger-text); }
.connection-tools { display: grid; justify-items: end; gap: .12rem; }
.access-link-button { min-height: 0; padding: 0; border: 0; background: transparent; color: var(--muted); font-size: .66rem; line-height: 1.2; text-decoration: underline; text-underline-offset: .12rem; white-space: nowrap; }
.access-link-button:hover { border-color: transparent; color: var(--accent-text); }
-.worker-summary-button { display: flex; align-items: center; gap: .45rem; min-height: 2.4rem; padding: .4rem .7rem; font-size: .8rem; font-weight: 700; white-space: nowrap; }
+.worker-summary-button { display: flex; align-items: center; gap: .45rem; min-height: 2.4rem; padding: .4rem .7rem; border: 1px solid var(--line); border-radius: .45rem; background: var(--surface); cursor: pointer; font-size: .8rem; font-weight: 700; white-space: nowrap; }
+.worker-summary-button:hover, .worker-menu[open] > .worker-summary-button { border-color: var(--accent); }
+.worker-summary-button:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
.worker-summary-button strong { min-width: 1ch; font-variant-numeric: tabular-nums; text-align: center; }
.worker-summary-state { color: var(--muted); font-size: .72rem; font-weight: 600; }
.worker-summary-state::before { content: "·"; margin-right: .45rem; color: var(--muted); }
@@ -64,6 +66,53 @@ button.danger { color: var(--danger-text); }
.worker-summary-button.has-active .worker-summary-active { color: var(--success); }
.worker-summary-button.has-attention:not(.has-active) { border-color: color-mix(in srgb, var(--warning) 75%, var(--line)); }
.worker-summary-button .worker-summary-attention { color: var(--warning); }
+.worker-overview-popover { position: absolute; top: calc(100% - .25rem); right: 1.5rem; z-index: 62; width: min(40rem, calc(100vw - 1.6rem)); max-height: calc(100vh - 6rem); overflow: auto; padding: .75rem; border: 1px solid var(--line); border-radius: .65rem; background: var(--surface-2); box-shadow: var(--shadow); }
+.worker-menu:has(.has-active) .worker-overview-popover { border-color: color-mix(in srgb, var(--success) 70%, var(--line)); }
+.worker-menu:has(.has-attention):not(:has(.has-active)) .worker-overview-popover { border-color: color-mix(in srgb, var(--warning) 70%, var(--line)); }
+#worker-overview-content { display: flex; flex-direction: column; min-height: 16rem; }
+.worker-overview-loading { margin: auto; color: var(--muted); }
+.worker-overview-header { display: flex; align-items: start; justify-content: space-between; gap: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--line); }
+.worker-overview-header h2 { margin: .12rem 0 0; font-size: 1.1rem; }
+.worker-overview-header p { margin: .25rem 0 0; color: var(--muted); font-size: .75rem; }
+.worker-overview-heading-actions { display: flex; align-items: center; gap: .45rem; }
+.worker-overview-heading-actions form { margin: 0; }
+.worker-overview-list {
+ display: grid;
+ gap: .7rem;
+ margin: 1rem 0 0;
+ padding: 0;
+ list-style: none;
+}
+.worker-overview-row { display: grid; gap: .65rem; padding: .85rem; border: 1px solid var(--line); border-left-width: .28rem; border-radius: .6rem; background: var(--surface-2); }
+.worker-overview-row.worker-overview-working { border-left-color: var(--success); background: color-mix(in srgb, var(--success) 9%, var(--surface)); }
+.worker-overview-row.worker-overview-preparing { border-left-color: var(--accent); }
+.worker-overview-row.worker-overview-stopping { border-left-color: var(--warning); }
+.worker-overview-row-heading { display: flex; align-items: start; justify-content: space-between; gap: .75rem; }
+.worker-overview-row-heading > div { display: grid; min-width: 0; gap: .1rem; }
+.worker-overview-row-heading strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; }
+.worker-overview-row-heading span, .worker-overview-current-item > span { color: var(--muted); font-size: .68rem; }
+.worker-overview-state { flex: none; padding: .18rem .45rem; border-radius: 999px; background: var(--surface); color: var(--text) !important; font-weight: 750; }
+.worker-overview-working .worker-overview-state { color: var(--success) !important; }
+.worker-overview-stopping .worker-overview-state { color: var(--warning) !important; }
+.worker-overview-current-item { display: grid; gap: .12rem; min-width: 0; }
+.worker-overview-current-item strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .8rem; }
+.worker-overview-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .5rem; margin: 0; }
+.worker-overview-facts > div { min-width: 0; }
+.worker-overview-facts dt { color: var(--muted); font-size: .6rem; font-weight: 750; letter-spacing: .05em; text-transform: uppercase; }
+.worker-overview-facts dd { margin: .12rem 0 0; overflow-wrap: anywhere; font-size: .72rem; }
+.worker-overview-actions { display: flex; justify-content: flex-end; gap: .45rem; }
+.worker-overview-actions form { margin: 0; }
+.worker-overview-actions button { min-height: 2rem; padding: .3rem .65rem; font-size: .72rem; }
+.worker-overview-empty { display: grid; justify-items: center; gap: .3rem; margin: auto; padding: 3rem 1rem; color: var(--muted); text-align: center; }
+.worker-overview-empty strong { color: var(--text); }
+.worker-overview-empty p { margin: 0; font-size: .8rem; }
+.worker-overview-footer { display: flex; align-items: center; justify-content: space-between; gap: .7rem; margin-top: 1rem; padding-top: .8rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .72rem; }
+.worker-overview-footer button { min-height: 2rem; padding: .25rem .55rem; font-size: .7rem; }
+@media (max-width: 620px) {
+ .worker-overview-header { align-items: stretch; flex-direction: column; }
+ .worker-overview-heading-actions form button { width: 100%; }
+ .worker-overview-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
.eyebrow { color: var(--accent-text); font-size: .65rem; font-weight: 800; letter-spacing: .14em; }
.connection { font-size: .82rem; color: var(--muted); }
.connection[data-state=connected]::before { content: "● "; color: var(--success); }
@@ -163,6 +212,8 @@ main { padding: 1rem 1.5rem 2rem; }
.worker-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .3rem .7rem; padding: .65rem; border-radius: .5rem; background: var(--surface-2); }
.worker-row.worker-stale { border: 1px solid var(--line); background: transparent; }
.worker-row.worker-stale strong { color: var(--muted); font-weight: 500; }
+.worker-row.worker-row-focused { animation: worker-row-focus 1.8s ease-out; outline: 3px solid color-mix(in srgb, var(--accent) 58%, transparent); outline-offset: 3px; }
+@keyframes worker-row-focus { 70%, 100% { outline-color: transparent; } }
.worker-row > div { display: grid; min-width: 0; }
.worker-row strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .8rem; }
.worker-row span, .worker-row small { color: var(--muted); font-size: .7rem; }
@@ -523,9 +574,12 @@ input::placeholder, textarea::placeholder { color: var(--muted); }
.card.claimed-other { border-color: color-mix(in srgb, var(--warning) 72%, var(--line)); background: color-mix(in srgb, var(--warning) 8%, var(--surface)); }
.card.claimed-other::before { background: var(--warning); }
.card.activity-needs-attention { border-color: color-mix(in srgb, var(--warning) 78%, var(--line)); background: color-mix(in srgb, var(--warning) 12%, var(--surface)); }
-.card.activity-needs-attention::before, .card.activity-queued::before, .card.activity-retry-scheduled::before, .card.activity-handoff-queued::before, .card.activity-agent-active::before, .card.activity-paused::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; }
+.card.activity-needs-attention::before, .card.activity-queued::before, .card.activity-retry-scheduled::before, .card.activity-handoff-queued::before, .card.activity-worker-preparing::before, .card.activity-agent-active::before, .card.activity-paused::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; }
.card.activity-needs-attention::before { background: var(--warning); }
-.card.activity-agent-active::before { background: var(--accent); }
+.card.activity-worker-preparing { border-color: color-mix(in srgb, var(--muted) 55%, var(--line)); background: color-mix(in srgb, var(--muted) 5%, var(--surface)); }
+.card.activity-worker-preparing::before { background: var(--muted); }
+.card.activity-agent-active { transform: translateY(-1px); border-color: var(--accent-strong); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent), 0 .75rem 1.5rem color-mix(in srgb, var(--accent) 28%, transparent); }
+.card.activity-agent-active::before { width: 6px; background: var(--accent-strong); }
.card.activity-queued::before { background: #7259a8; }
.card.activity-retry-scheduled::before { background: #a36a00; }
.card.activity-handoff-queued::before { background: #7259a8; }
@@ -539,6 +593,18 @@ input::placeholder, textarea::placeholder { color: var(--muted); }
.card-title { font-weight: 670; line-height: 1.25; }
.activity-badge { width: max-content; max-width: 100%; padding: .12rem .42rem; border-radius: 999px; background: color-mix(in srgb, var(--accent) 13%, var(--surface)); color: var(--accent-text); font-size: .68rem; font-weight: 750; }
.activity-needs-attention .activity-badge { background: color-mix(in srgb, var(--warning) 18%, var(--surface)); color: var(--warning); }
+.activity-worker-preparing .activity-badge { background: color-mix(in srgb, var(--muted) 14%, var(--surface)); color: var(--text); }
+.agent-working-banner { position: relative; display: flex; align-items: center; gap: .6rem; width: calc(100% + 1.5rem); min-width: 0; margin: .05rem -.75rem 0; overflow: hidden; padding: .48rem .75rem .55rem; background: linear-gradient(110deg, var(--accent-strong), var(--accent)); color: var(--accent-contrast); font-size: .72rem; font-weight: 800; letter-spacing: .02em; }
+.agent-working-banner::after { content: ""; position: absolute; inset: auto 0 0; height: 3px; background: color-mix(in srgb, var(--accent-contrast) 68%, transparent); }
+.agent-working-live { display: inline-flex; align-items: center; gap: .32rem; flex: none; padding-right: .6rem; border-right: 1px solid color-mix(in srgb, var(--accent-contrast) 42%, transparent); font-size: .62rem; letter-spacing: .13em; text-transform: uppercase; }
+.agent-working-live::before { content: ""; width: .48rem; height: .48rem; flex: none; border-radius: 999px; background: var(--accent-contrast); box-shadow: 0 0 0 .18rem color-mix(in srgb, var(--accent-contrast) 22%, transparent); }
+.agent-working-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+@media (prefers-reduced-motion: no-preference) {
+ .agent-working-live::before { animation: agent-working-pulse 1.6s ease-in-out infinite; }
+ .agent-working-banner::after { right: auto; left: -42%; width: 42%; animation: agent-working-rail 2.2s ease-in-out infinite; }
+}
+@keyframes agent-working-pulse { 50% { opacity: .5; transform: scale(.76); } }
+@keyframes agent-working-rail { to { left: 100%; } }
.activity-queued .activity-badge { background: color-mix(in srgb, #7259a8 18%, var(--surface)); color: color-mix(in srgb, #7259a8 78%, var(--text)); }
.activity-retry-scheduled .activity-badge { background: color-mix(in srgb, #a36a00 18%, var(--surface)); color: color-mix(in srgb, #a36a00 85%, var(--text)); }
.activity-handoff-queued .activity-badge { background: color-mix(in srgb, #7259a8 18%, var(--surface)); color: color-mix(in srgb, #7259a8 78%, var(--text)); }
@@ -603,6 +669,7 @@ input::placeholder, textarea::placeholder { color: var(--muted); }
.worker-state-callout.queued { border-color: color-mix(in srgb, #7259a8 65%, var(--line)); background: color-mix(in srgb, #7259a8 10%, var(--surface)); }
.worker-state-callout.retry-scheduled { border-color: color-mix(in srgb, #a36a00 65%, var(--line)); background: color-mix(in srgb, #a36a00 10%, var(--surface)); }
.worker-state-callout.handoff-queued { border-color: color-mix(in srgb, #7259a8 65%, var(--line)); background: color-mix(in srgb, #7259a8 10%, var(--surface)); }
+.worker-state-callout.worker-preparing { border-color: color-mix(in srgb, var(--muted) 55%, var(--line)); background: color-mix(in srgb, var(--muted) 6%, var(--surface)); }
.worker-state-callout.agent-active { border-color: color-mix(in srgb, var(--accent) 70%, var(--line)); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); }
.worker-state-callout.completed { border-color: color-mix(in srgb, var(--success, #3a9d5d) 65%, var(--line)); background: color-mix(in srgb, var(--success, #3a9d5d) 10%, var(--surface)); }
.worker-state-callout.provider-unavailable { border-color: color-mix(in srgb, #a36a00 65%, var(--line)); background: color-mix(in srgb, #a36a00 10%, var(--surface)); }
@@ -768,6 +835,7 @@ input::placeholder, textarea::placeholder { color: var(--muted); }
.app-header-status { width: 100%; justify-content: flex-start; gap: .5rem; }
.app-header-status .connection-tools { margin-left: auto; }
.agents-popover { right: .8rem; }
+ .worker-overview-popover { right: .8rem; }
.agents-popover-header { align-items: stretch; flex-direction: column; }
.agents-quick-actions { justify-content: flex-start; }
.board { display: grid; grid-auto-flow: row; grid-auto-columns: auto; overflow: visible; }
diff --git a/src/Highbyte.Wrighty.Web/HostedWorkerEventProjection.cs b/src/Highbyte.Wrighty.Web/HostedWorkerEventProjection.cs
index 8ca86fbd..eb79223b 100644
--- a/src/Highbyte.Wrighty.Web/HostedWorkerEventProjection.cs
+++ b/src/Highbyte.Wrighty.Web/HostedWorkerEventProjection.cs
@@ -15,14 +15,16 @@ public static HostedWorkerProjectedState Apply(
string? agent,
WorkerEvent value)
{
- var running = value.Type is "started" or "resumed" or "running" or "session";
+ var preparing = value.Type == "preparing";
+ var running = value.Type is "started" or "resumed" or "retry-started" or
+ "handoff-started" or "requirements-assessment-started" or "running" or "session";
var terminal = value.Type is "finished" or "needs-attention" or "failed" or
"fenced" or "timed-out" or "rejected" or "retry-scheduled" or "interrupted";
var stopping = state is WebHostedWorkerState.Draining or
WebHostedWorkerState.StoppingNow or WebHostedWorkerState.Finalizing;
state = ProjectAmbientState(state, value.Type, stopping);
- if (running && value.ItemId is not null)
+ if ((preparing || running) && value.ItemId is not null)
{
if (!stopping)
state = WebHostedWorkerState.Running;
@@ -70,6 +72,7 @@ public static string Level(WorkerEvent value) =>
{
"idle" or "no-item" or "retry-scheduled" or "workspace-busy" or
"agent-unavailable" or "provider-unavailable" => SafeMessage(value.Message),
+ "preparing" => "The worker is preparing the item for its agent.",
"started" => "The agent session started.",
"resumed" => "The retained agent session resumed.",
"running" or "session" => "The agent session is running.",
diff --git a/src/Highbyte.Wrighty.Web/ItemOrganization.cs b/src/Highbyte.Wrighty.Web/ItemOrganization.cs
index 862c5dca..c685fe57 100644
--- a/src/Highbyte.Wrighty.Web/ItemOrganization.cs
+++ b/src/Highbyte.Wrighty.Web/ItemOrganization.cs
@@ -211,6 +211,20 @@ private static void Append(StringBuilder builder, string name, IEnumerable activity switch
+ {
+ OperationalStatuses.AgentActive => 0,
+ OperationalStatuses.WorkerPreparing => 1,
+ OperationalStatuses.NeedsAttention => 2,
+ OperationalStatuses.RetryScheduled => 3,
+ OperationalStatuses.HandoffQueued => 4,
+ OperationalStatuses.Queued => 5,
+ _ => 6
+ };
+}
+
public sealed class BoardCardComparer(
ItemSort sort,
IReadOnlyList priorities) : IComparer
@@ -236,8 +250,8 @@ public int Compare(BoardCardModel? left, BoardCardModel? right)
private int CompareDefault(BoardCardModel left, BoardCardModel right)
{
- var compared = OperationalRank(left.OperationalStatus).CompareTo(
- OperationalRank(right.OperationalStatus));
+ var compared = OperationalStatusOrder.Rank(left.OperationalStatus).CompareTo(
+ OperationalStatusOrder.Rank(right.OperationalStatus));
if (compared != 0) return compared;
compared = Optional(Priority(left.Priority), Priority(right.Priority), descending: false);
return compared != 0 ? compared : Number(left).CompareTo(Number(right));
@@ -283,15 +297,6 @@ internal static int Number(BoardCardModel card)
: int.MaxValue;
}
- private static int OperationalRank(string activity) => activity switch
- {
- OperationalStatuses.NeedsAttention => 0,
- OperationalStatuses.AgentActive => 1,
- OperationalStatuses.RetryScheduled => 2,
- OperationalStatuses.HandoffQueued => 3,
- OperationalStatuses.Queued => 4,
- _ => 5
- };
}
public sealed class OperationsListInput
@@ -309,6 +314,15 @@ public sealed class OperationsListInput
public string? ClaimState { get; set; }
}
+public sealed class WorkerStopInput
+{
+ public string RunId { get; set; } = string.Empty;
+ public int ProcessId { get; set; }
+ public string? ProcessStartIdentity { get; set; }
+ public string HostKind { get; set; } = string.Empty;
+ public string Mode { get; set; } = string.Empty;
+}
+
public sealed record OperationsListQuery(
ItemSort Sort,
string? Search,
@@ -477,8 +491,8 @@ public int Compare(OperationsItemView? left, OperationsItemView? right)
private int CompareDefault(OperationsItemView left, OperationsItemView right)
{
- var compared = OperationalRank(left.OperationalStatus).CompareTo(
- OperationalRank(right.OperationalStatus));
+ var compared = OperationalStatusOrder.Rank(left.OperationalStatus).CompareTo(
+ OperationalStatusOrder.Rank(right.OperationalStatus));
if (compared != 0) return compared;
compared = Optional(Priority(left.Priority), Priority(right.Priority), descending: false);
return compared != 0 ? compared : Number(left.Id).CompareTo(Number(right.Id));
@@ -521,13 +535,4 @@ private static int Number(string id)
: int.MaxValue;
}
- private static int OperationalRank(string activity) => activity switch
- {
- OperationalStatuses.NeedsAttention => 0,
- OperationalStatuses.AgentActive => 1,
- OperationalStatuses.RetryScheduled => 2,
- OperationalStatuses.HandoffQueued => 3,
- OperationalStatuses.Queued => 4,
- _ => 5
- };
}
diff --git a/src/Highbyte.Wrighty.Web/Pages/Index.cshtml b/src/Highbyte.Wrighty.Web/Pages/Index.cshtml
index 0c6c6e58..d9facfe3 100644
--- a/src/Highbyte.Wrighty.Web/Pages/Index.cshtml
+++ b/src/Highbyte.Wrighty.Web/Pages/Index.cshtml
@@ -30,13 +30,13 @@