Files
nexus/backend/Services/OpenClawRunService.cs
T
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

687 lines
24 KiB
C#

using System.Text.Json.Nodes;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class OpenClawRunService(
IOpenClawRunRepository runs,
IOpenClawRunGateway gateway) : IOpenClawRunService
{
private const string ResumeCapabilityMessage =
"OpenClaw exposes chat.send/chat.abort/history, but no durable same-run resume RPC. "
+ "Use Retry to create a correlated new run or start a follow-up turn.";
public async Task<OpenClawRunCollectionDto> GetAsync(
OpenClawRunQuery query,
CancellationToken cancellationToken = default)
{
var limit = Math.Clamp(query.Limit, 1, 200);
var items = await runs.GetAsync(query with { Limit = limit + 1 }, cancellationToken);
var hasMore = items.Count > limit;
var selected = items.Take(limit).ToList();
var nextCursor = hasMore && selected.Count > 0
? OpenClawRunCursorCodec.Encode(
selected[^1].CreatedAt,
selected[^1].Id)
: null;
return new OpenClawRunCollectionDto(
selected.Select(Map).ToList(),
nextCursor,
DateTimeOffset.UtcNow);
}
public async Task<OpenClawRunDto?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
var run = await runs.GetByIdAsync(id, cancellationToken: cancellationToken);
return run is null ? null : Map(run);
}
public async Task<OpenClawRunOperationDto> StartAsync(
StartOpenClawRunRequest request,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
{
var existing = await runs.GetByStartIdempotencyKeyAsync(
invocation.IdempotencyKey,
cancellationToken);
if (existing is not null)
{
var sameCommand = string.Equals(existing.Prompt, request.Prompt.Trim(), StringComparison.Ordinal)
&& string.Equals(existing.AgentId, request.AgentId.Trim(), StringComparison.Ordinal)
&& string.Equals(existing.SessionKey, request.SessionKey.Trim(), StringComparison.Ordinal);
if (sameCommand
&& existing.Status == OpenClawRunStates.Dispatching
&& string.IsNullOrWhiteSpace(existing.OpenClawRunId))
{
var recoverable = await runs.GetByIdAsync(
existing.Id,
tracking: true,
cancellationToken: cancellationToken);
if (recoverable is not null)
{
var recovered = await gateway.StartAsync(recoverable, invocation, cancellationToken);
ApplyDispatchResult(recoverable, recovered);
await runs.UpdateAsync(
recoverable,
History(
recoverable,
"start_recovery",
OpenClawRunStates.Dispatching,
recoverable.Status,
$"Idempotent dispatch recovery: {recovered.Message}",
invocation,
idempotencyKey: null),
cancellationToken);
return Operation(
recovered.Ok,
recovered.State,
recovered.Message,
recoverable);
}
}
return Operation(
sameCommand,
sameCommand ? "idempotent_replay" : "idempotency_conflict",
sameCommand
? "The original start result was returned; OpenClaw was not invoked again."
: "The idempotency key is already bound to a different run command.",
existing);
}
await ValidateCorrelationsAsync(request.TaskId, request.ProjectId, cancellationToken);
var now = DateTimeOffset.UtcNow;
var run = new OpenClawRun
{
Title = BuildTitle(request.Title, request.Prompt),
Prompt = request.Prompt.Trim(),
AgentId = request.AgentId.Trim(),
SessionKey = request.SessionKey.Trim(),
TaskId = request.TaskId,
ProjectId = request.ProjectId,
Status = OpenClawRunStates.Dispatching,
StartIdempotencyKey = invocation.IdempotencyKey,
CorrelationId = invocation.CorrelationId,
Actor = invocation.Actor,
TraceParent = invocation.TraceParent,
CreatedAt = now,
UpdatedAt = now
};
await runs.AddAsync(
run,
History(
run,
"start_requested",
OpenClawRunStates.Dispatching,
OpenClawRunStates.Dispatching,
"Nexus durably recorded the run before dispatch.",
invocation),
cancellationToken);
var result = await gateway.StartAsync(run, invocation, cancellationToken);
ApplyDispatchResult(run, result);
await runs.UpdateAsync(
run,
History(
run,
"start_result",
OpenClawRunStates.Dispatching,
run.Status,
result.Message,
invocation,
idempotencyKey: null),
cancellationToken);
return Operation(result.Ok, result.State, result.Message, run);
}
public async Task<OpenClawRunOperationDto?> StopAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
{
var run = await runs.GetByIdAsync(id, tracking: true, cancellationToken: cancellationToken);
if (run is null)
return null;
var replay = await runs.GetInvocationAsync(
id,
"stop_requested",
invocation.IdempotencyKey,
cancellationToken);
if (replay is not null)
{
return Operation(
true,
"idempotent_replay",
"The original stop result was returned; OpenClaw was not invoked again.",
run);
}
if (OpenClawRunStates.IsTerminal(run.Status))
{
var message = $"Run is already terminal with state '{run.Status}'.";
await runs.UpdateAsync(
run,
History(
run,
"stop_requested",
run.Status,
run.Status,
message,
invocation),
cancellationToken);
return Operation(true, "already_terminal", message, run);
}
var previousState = run.Status;
run.Status = OpenClawRunStates.Stopping;
await runs.UpdateAsync(
run,
History(
run,
"stop_requested",
previousState,
OpenClawRunStates.Stopping,
BuildReasonMessage("Stop requested.", reason),
invocation),
cancellationToken);
var result = await gateway.StopAsync(run, invocation, cancellationToken);
run.Status = result.Ok ? OpenClawRunStates.Stopped : previousState;
run.LastError = result.Ok ? null : result.Message;
if (result.Ok)
run.FinishedAt = DateTimeOffset.UtcNow;
await runs.UpdateAsync(
run,
History(
run,
"stop_result",
OpenClawRunStates.Stopping,
run.Status,
result.Message,
invocation,
idempotencyKey: null),
cancellationToken);
return Operation(result.Ok, result.State, result.Message, run);
}
public async Task<OpenClawRunOperationDto?> ResumeAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
{
var run = await runs.GetByIdAsync(id, tracking: true, cancellationToken: cancellationToken);
if (run is null)
return null;
var replay = await runs.GetInvocationAsync(
id,
"resume_requested",
invocation.IdempotencyKey,
cancellationToken);
if (replay is not null)
{
return Operation(
false,
OpenClawRunStates.Unsupported,
ResumeCapabilityMessage,
run);
}
await runs.UpdateAsync(
run,
History(
run,
"resume_requested",
run.Status,
run.Status,
BuildReasonMessage(ResumeCapabilityMessage, reason),
invocation),
cancellationToken);
return Operation(
false,
OpenClawRunStates.Unsupported,
ResumeCapabilityMessage,
run);
}
public async Task<OpenClawRunOperationDto?> RetryAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
{
var source = await runs.GetByIdAsync(id, tracking: true, cancellationToken: cancellationToken);
if (source is null)
return null;
var existing = await runs.GetByStartIdempotencyKeyAsync(
invocation.IdempotencyKey,
cancellationToken);
if (existing is not null)
{
var sameSource = existing.RetriedFromRunId == id;
if (sameSource
&& existing.Status == OpenClawRunStates.Dispatching
&& string.IsNullOrWhiteSpace(existing.OpenClawRunId))
{
var recoverable = await runs.GetByIdAsync(
existing.Id,
tracking: true,
cancellationToken: cancellationToken);
if (recoverable is not null)
{
var recovered = await gateway.StartAsync(recoverable, invocation, cancellationToken);
ApplyDispatchResult(recoverable, recovered);
await runs.UpdateAsync(
recoverable,
History(
recoverable,
"start_recovery",
OpenClawRunStates.Dispatching,
recoverable.Status,
$"Idempotent retry dispatch recovery: {recovered.Message}",
invocation,
idempotencyKey: null),
cancellationToken);
return Operation(
recovered.Ok,
recovered.State,
recovered.Message,
source,
recoverable);
}
}
return Operation(
sameSource,
sameSource ? "idempotent_replay" : "idempotency_conflict",
sameSource
? "The original retry result was returned; OpenClaw was not invoked again."
: "The idempotency key is already bound to another run.",
source,
existing);
}
var rejectedReplay = await runs.GetInvocationAsync(
id,
"retry_rejected",
invocation.IdempotencyKey,
cancellationToken);
if (rejectedReplay is not null)
{
return Operation(
false,
"invalid_state",
"Only a terminal, blocked, or unsupported run can be retried.",
source);
}
if (!OpenClawRunStates.IsTerminal(source.Status))
{
const string message = "Only a terminal, blocked, or unsupported run can be retried.";
await runs.UpdateAsync(
source,
History(
source,
"retry_rejected",
source.Status,
source.Status,
BuildReasonMessage(message, reason),
invocation),
cancellationToken);
return Operation(false, "invalid_state", message, source);
}
var now = DateTimeOffset.UtcNow;
var retry = new OpenClawRun
{
Title = source.Title,
Prompt = source.Prompt,
AgentId = source.AgentId,
SessionKey = source.SessionKey,
TaskId = source.TaskId,
ProjectId = source.ProjectId,
RetriedFromRunId = source.Id,
Status = OpenClawRunStates.Dispatching,
StartIdempotencyKey = invocation.IdempotencyKey,
CorrelationId = invocation.CorrelationId,
Actor = invocation.Actor,
TraceParent = invocation.TraceParent,
CreatedAt = now,
UpdatedAt = now
};
await runs.AddRetryAsync(
source,
retry,
History(
source,
"retry_requested",
source.Status,
source.Status,
BuildReasonMessage($"Retry created run {retry.Id}.", reason),
invocation,
resultRunId: retry.Id),
History(
retry,
"start_requested",
OpenClawRunStates.Dispatching,
OpenClawRunStates.Dispatching,
$"Retry of Nexus run {source.Id} was durably recorded before dispatch.",
invocation),
cancellationToken);
var result = await gateway.StartAsync(retry, invocation, cancellationToken);
ApplyDispatchResult(retry, result);
await runs.UpdateAsync(
retry,
History(
retry,
"start_result",
OpenClawRunStates.Dispatching,
retry.Status,
result.Message,
invocation,
idempotencyKey: null),
cancellationToken);
return Operation(result.Ok, result.State, result.Message, source, retry);
}
public async Task<OpenClawRunHistoryResponse?> GetHistoryAsync(
Guid id,
int gatewayLimit = 200,
CancellationToken cancellationToken = default)
{
var run = await runs.GetByIdAsync(id, cancellationToken: cancellationToken);
if (run is null)
return null;
var transitions = await runs.GetHistoryAsync(id, cancellationToken);
var gatewayHistory = await gateway.GetHistoryAsync(
run,
Math.Clamp(gatewayLimit, 1, 1000),
cancellationToken);
return new OpenClawRunHistoryResponse(
Map(run),
transitions.Select(Map).ToList(),
gatewayHistory.State,
gatewayHistory.Data,
gatewayHistory.Message,
DateTimeOffset.UtcNow);
}
public async Task ReconcileAsync(
GatewayEventEnvelope gatewayEvent,
CancellationToken cancellationToken = default)
{
if (!IsRunEvent(gatewayEvent.Event))
return;
var runId = ReadString(gatewayEvent.Payload?["runId"]);
var state = ReadString(gatewayEvent.Payload?["state"])?.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(runId) || string.IsNullOrWhiteSpace(state))
return;
var projectedState = MapGatewayState(state);
if (projectedState is null)
return;
var gatewayEventId = OpenClawEventIdentity.Create(gatewayEvent);
if (await runs.HasGatewayEventAsync(gatewayEventId, cancellationToken))
return;
var run = await runs.GetByOpenClawRunIdAsync(runId, cancellationToken);
if (run is null)
return;
var sequence = ReadLong(gatewayEvent.Payload?["seq"]);
if (sequence.HasValue
&& run.LastGatewaySequence.HasValue
&& sequence.Value <= run.LastGatewaySequence.Value)
{
return;
}
var previousStatus = run.Status;
var sequenceGap = sequence.HasValue
&& run.LastGatewaySequence.HasValue
&& sequence.Value > run.LastGatewaySequence.Value + 1;
if (!OpenClawRunStates.IsTerminal(run.Status) || OpenClawRunStates.IsTerminal(projectedState))
run.Status = projectedState;
if (sequence.HasValue)
run.LastGatewaySequence = sequence;
run.SequenceGapDetected |= sequenceGap;
if (run.StartedAt is null && projectedState == OpenClawRunStates.Running)
run.StartedAt = gatewayEvent.ReceivedAt;
if (OpenClawRunStates.IsTerminal(projectedState))
run.FinishedAt = gatewayEvent.ReceivedAt;
if (projectedState == OpenClawRunStates.Failed)
run.LastError = ReadString(gatewayEvent.Payload?["errorMessage"]) ?? "OpenClaw run failed.";
else if (projectedState is OpenClawRunStates.Completed or OpenClawRunStates.Stopped)
run.LastError = null;
var shouldAudit = !string.Equals(previousStatus, run.Status, StringComparison.Ordinal)
|| sequenceGap
|| OpenClawRunStates.IsTerminal(projectedState);
OpenClawRunHistory? history = null;
if (shouldAudit)
{
history = new OpenClawRunHistory
{
RunId = run.Id,
Action = "gateway_event",
FromStatus = previousStatus,
ToStatus = run.Status,
Message = sequenceGap
? $"Gateway event '{gatewayEvent.Event}' reported a per-run sequence gap."
: $"Gateway event '{gatewayEvent.Event}' projected run state '{state}'.",
Actor = "openclaw-gateway",
CorrelationId = run.CorrelationId,
TraceParent = run.TraceParent,
GatewayEventId = gatewayEventId,
GatewaySequence = sequence,
SequenceGapDetected = sequenceGap,
OccurredAt = gatewayEvent.ReceivedAt
};
}
await runs.UpdateProjectionAsync(run, history, cancellationToken);
}
private async Task ValidateCorrelationsAsync(
Guid? taskId,
Guid? projectId,
CancellationToken cancellationToken)
{
if (taskId.HasValue && !await runs.TaskExistsAsync(taskId.Value, cancellationToken))
throw new OpenClawRunValidationException("taskId", "The correlated Nexus task does not exist.");
if (projectId.HasValue && !await runs.ProjectExistsAsync(projectId.Value, cancellationToken))
throw new OpenClawRunValidationException("projectId", "The correlated Nexus project does not exist.");
}
private static void ApplyDispatchResult(OpenClawRun run, OpenClawRunGatewayResult result)
{
run.Status = result.State;
run.OpenClawRunId = result.OpenClawRunId ?? run.OpenClawRunId;
run.LastError = result.Ok ? null : result.Message;
if (result.Ok && run.StartedAt is null)
run.StartedAt = DateTimeOffset.UtcNow;
if (result.Ok && result.State == OpenClawRunStates.Completed)
run.FinishedAt = DateTimeOffset.UtcNow;
}
private static OpenClawRunHistory History(
OpenClawRun run,
string action,
string fromStatus,
string toStatus,
string message,
OpenClawInvocationMetadata invocation,
string? idempotencyKey = "__invocation__",
Guid? resultRunId = null)
=> new()
{
RunId = run.Id,
Action = action,
FromStatus = fromStatus,
ToStatus = toStatus,
Message = message,
Actor = invocation.Actor,
CorrelationId = invocation.CorrelationId,
IdempotencyKey = idempotencyKey == "__invocation__"
? invocation.IdempotencyKey
: idempotencyKey,
TraceParent = invocation.TraceParent,
ResultRunId = resultRunId
};
private static OpenClawRunOperationDto Operation(
bool ok,
string state,
string message,
OpenClawRun run,
OpenClawRun? resultRun = null)
=> new(
ok,
state,
message,
Map(run),
resultRun is null ? null : Map(resultRun),
DateTimeOffset.UtcNow,
new OperationResultDto(
run.CorrelationId,
state,
run.Revision,
new EntityRefDto("run", run.Id.ToString(), run.Title),
resultRun is null
? []
: [new EntityRefDto(
"run",
resultRun.Id.ToString(),
resultRun.Title)],
run.TraceParent));
private static OpenClawRunDto Map(OpenClawRun run)
=> new(
run.Id,
run.Title,
run.Prompt,
run.AgentId,
run.SessionKey,
run.Status,
run.TaskId,
run.ProjectId,
run.OpenClawRunId,
run.RetriedFromRunId,
run.CorrelationId,
run.Actor,
run.LastError,
run.LastGatewaySequence,
run.SequenceGapDetected,
run.Status is OpenClawRunStates.Dispatching or OpenClawRunStates.Running or OpenClawRunStates.Stopping,
OpenClawRunStates.IsTerminal(run.Status),
false,
ResumeCapabilityMessage,
run.CreatedAt,
run.UpdatedAt,
run.StartedAt,
run.FinishedAt);
private static OpenClawRunHistoryDto Map(OpenClawRunHistory item)
=> new(
item.Id,
item.RunId,
item.Action,
item.FromStatus,
item.ToStatus,
item.Message,
item.Actor,
item.CorrelationId,
item.IdempotencyKey,
item.TraceParent,
item.GatewayEventId,
item.GatewaySequence,
item.SequenceGapDetected,
item.ResultRunId,
item.OccurredAt);
private static string BuildTitle(string? requestedTitle, string prompt)
{
var title = string.IsNullOrWhiteSpace(requestedTitle)
? prompt.Replace("\r", " ", StringComparison.Ordinal)
.Replace("\n", " ", StringComparison.Ordinal)
.Trim()
: requestedTitle.Trim();
return title.Length <= 160 ? title : title[..157] + "...";
}
private static string BuildReasonMessage(string message, string? reason)
=> string.IsNullOrWhiteSpace(reason)
? message
: $"{message} Reason: {reason.Trim()}";
private static bool IsRunEvent(string eventName)
=> eventName.Equals("chat", StringComparison.OrdinalIgnoreCase)
|| eventName.Equals("agent", StringComparison.OrdinalIgnoreCase)
|| eventName.Equals("session.operation", StringComparison.OrdinalIgnoreCase);
private static string? MapGatewayState(string state)
=> state switch
{
"status" or "delta" or "started" or "running" => OpenClawRunStates.Running,
"final" or "completed" or "succeeded" => OpenClawRunStates.Completed,
"aborted" or "cancelled" => OpenClawRunStates.Stopped,
"error" or "failed" => OpenClawRunStates.Failed,
_ => null
};
private static string? ReadString(JsonNode? node)
{
try
{
return node?.GetValue<string>();
}
catch
{
return null;
}
}
private static long? ReadLong(JsonNode? node)
{
try
{
if (node is null)
return null;
return node.GetValueKind() switch
{
System.Text.Json.JsonValueKind.Number => node.GetValue<long>(),
System.Text.Json.JsonValueKind.String when long.TryParse(node.GetValue<string>(), out var value) => value,
_ => null
};
}
catch
{
return null;
}
}
}
public sealed class OpenClawRunValidationException(string field, string message) : Exception(message)
{
public string Field { get; } = field;
}