Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bound-outcome-plan-drafting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agentweaver": patch
---

Fail coordinator runs when outcome-plan drafting cannot finish provider startup, session creation, or the model turn within a bounded deadline, and keep the displayed elapsed time advancing from the durable run start.
13 changes: 13 additions & 0 deletions apps/Agentweaver.Api/Coordinator/CoordinatorMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,16 @@ public sealed record CoordinatorOutcomeSpecDecision(

/// <summary>Terminal workflow output for a coordinator run.</summary>
public sealed record CoordinatorOutcome(string RunId, int SpecId, string Status);

/// <summary>
/// Raised when outcome-spec drafting exceeds its coordinator-level wall-clock bound. The bound
/// covers provider setup and session creation as well as the model turn, which have looser runtime
/// defaults and can otherwise leave the durable coordinator run in <c>drafting</c> indefinitely.
/// </summary>
public sealed class CoordinatorOutcomeSpecDraftTimeoutException(
string runId,
TimeSpan timeout,
Exception? innerException = null)
: TimeoutException(
$"Coordinator outcome-spec drafting for run '{runId}' exceeded {timeout.TotalSeconds:n0} seconds.",
innerException);
64 changes: 63 additions & 1 deletion apps/Agentweaver.Api/Coordinator/CoordinatorRunService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,14 @@ private void StartWatching(
"User must re-link their GitHub account.", runId);
await FailRunSafeAsync(runId, entry, GitHubCopilotUnauthorizedException.AuthRequiredErrorCode).ConfigureAwait(false);
}
catch (Exception ex) when (ContainsOutcomeSpecDraftTimeout(ex))
{
_logger.LogError(
ex,
"Coordinator run {RunId} exceeded the outcome-spec drafting deadline; transitioning to Failed",
runId);
await FailRunSafeAsync(runId, entry, "outcome_spec_draft_timeout").ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Coordinator watch loop failed for run {RunId}; transitioning to Failed", runId);
Expand All @@ -854,13 +862,32 @@ private void StartWatching(
}, _appStopping);
}

private static bool ContainsOutcomeSpecDraftTimeout(Exception? exception) =>
exception is CoordinatorOutcomeSpecDraftTimeoutException
|| (exception?.InnerException is not null && ContainsOutcomeSpecDraftTimeout(exception.InnerException));

private async Task WatchAsync(
string runId, StreamingRun streamingRun, RunStreamEntry entry, string ownerUser, CancellationToken ct)
{
await foreach (var evt in streamingRun.WatchStreamAsync(ct).ConfigureAwait(false))
{
switch (evt)
{
case ExecutorFailedEvent failed:
var isDraftTimeout = ContainsOutcomeSpecDraftTimeout(failed.Data);
var providerFailure = isDraftTimeout ? null : FindProviderFailure(failed.Data);
var reason = isDraftTimeout
? "outcome_spec_draft_timeout"
: providerFailure?.ErrorCode ?? $"coordinator_executor_failed:{failed.ExecutorId}";
_logger.LogError(
failed.Data,
"Coordinator executor {ExecutorId} failed for run {RunId}; transitioning to Failed with {Reason}",
failed.ExecutorId,
runId,
reason);
await FailRunSafeAsync(runId, entry, reason, providerFailure).ConfigureAwait(false);
return;

case RequestInfoEvent rie:
// Suspended at the await-confirmation gate. The draft executor already emitted
// coordinator.outcome_spec and marked the entry awaiting-review.
Expand Down Expand Up @@ -1680,6 +1707,13 @@ private async Task<bool> IsDelegatedPlanAsync(string runId)
}

private async Task FailRunSafeAsync(string runId, RunStreamEntry entry, string reason = "watch_loop_error")
=> await FailRunSafeAsync(runId, entry, reason, providerFailure: null).ConfigureAwait(false);

private async Task FailRunSafeAsync(
string runId,
RunStreamEntry entry,
string reason,
AgentProviderException? providerFailure)
{
try
{
Expand All @@ -1697,7 +1731,24 @@ private async Task FailRunSafeAsync(string runId, RunStreamEntry entry, string r
runId);
return;
}
entry.RecordNext(EventTypes.RunFailed, new { reason });
if (!entry.HasEventType(EventTypes.RunFailed))
{
if (providerFailure is null)
{
entry.RecordNext(EventTypes.RunFailed, new { reason });
}
else
{
entry.RecordNext(EventTypes.RunFailed, new
{
reason = providerFailure.ErrorCode,
errorCode = providerFailure.ErrorCode,
message = providerFailure.UserMessage,
category = providerFailure.FailureKind.ToString(),
retryable = providerFailure.IsRetryable,
});
}
}
_streamStore.Complete(runId);
_ = _runWorkflowFactory.PersistRunEventsAsync(runId);
}
Expand All @@ -1715,6 +1766,17 @@ private async Task FailRunSafeAsync(string runId, RunStreamEntry entry, string r
}
}

private static AgentProviderException? FindProviderFailure(Exception? exception)
{
for (var current = exception; current is not null; current = current.InnerException)
{
if (current is AgentProviderException providerFailure)
return providerFailure;
}

return null;
}

/// <summary>
/// Releases the AgentHost pod for <paramref name="runId"/> when running pod-per-run. Best-effort:
/// logs and swallows exceptions so a release failure never disrupts run finalization. No-op when
Expand Down
103 changes: 102 additions & 1 deletion apps/Agentweaver.Api/Coordinator/CoordinatorWorkflowFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public sealed class CoordinatorWorkflowFactory
private const string InputStateKey = "coordinator-input";
private const string InputStateScope = "run-context";
private const string CoordinatorAgentName = "Coordinator";
private const int DefaultOutcomeSpecDraftTimeoutSeconds = 120;

private const string FallbackCharter =
"You are the Coordinator, the built-in orchestration agent. Restate the human's goal as a " +
Expand All @@ -46,6 +47,7 @@ public sealed class CoordinatorWorkflowFactory
private readonly string _checkpointDir;
private readonly ICheckpointStoreFactory _checkpointStoreFactory;
private readonly CoordinatorOrchestratorExecutor _orchestrator;
private readonly TimeSpan _outcomeSpecDraftTimeout;

public CoordinatorWorkflowFactory(
IWorkflowAgentFactory agentFactory,
Expand All @@ -64,6 +66,14 @@ public CoordinatorWorkflowFactory(
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<CoordinatorWorkflowFactory>();

var outcomeSpecDraftTimeoutSeconds = configuration.GetValue(
"Coordinator:OutcomeSpecDraftTimeoutSeconds",
DefaultOutcomeSpecDraftTimeoutSeconds);
if (outcomeSpecDraftTimeoutSeconds <= 0)
throw new InvalidOperationException(
"Coordinator:OutcomeSpecDraftTimeoutSeconds must be greater than zero.");
_outcomeSpecDraftTimeout = TimeSpan.FromSeconds(outcomeSpecDraftTimeoutSeconds);

_checkpointDir = configuration["Coordinator:Checkpoints:Path"]
?? Path.Combine(AppPaths.DataDirectory, "coordinator-checkpoints");
_checkpointStoreFactory = checkpointStoreFactory ?? new FileCheckpointStoreFactory();
Expand Down Expand Up @@ -338,7 +348,7 @@ private async Task<CoordinatorOutcomeSpecRequest> DraftAndPersistAsync(
var memoryContext = await CompileMemoryContextAsync(input.ProjectId, ct).ConfigureAwait(false);
var charter = BuiltInCharterResolver.Resolve(input.RepositoryPath, "coordinator") ?? FallbackCharter;

var draft = await _drafter.DraftAsync(input, charter, memoryContext, ct).ConfigureAwait(false);
var draft = await DraftWithTimeoutAsync(input, charter, memoryContext, ct).ConfigureAwait(false);

var (specId, status) = await PersistDraftAsync(input, draft, ct).ConfigureAwait(false);

Expand All @@ -362,6 +372,97 @@ private async Task<CoordinatorOutcomeSpecRequest> DraftAndPersistAsync(
draft.DesiredOutcome, draft.Scope, draft.Assumptions, draft.ClarifyingQuestions, status);
}

private async Task<OutcomeSpecDraft> DraftWithTimeoutAsync(
CoordinatorDraftInput input,
string charter,
string? memoryContext,
CancellationToken ct)
{
var draftCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var cleanupDeferred = false;
try
{
var draftTask = _drafter.DraftAsync(input, charter, memoryContext, draftCts.Token);
var timeoutTask = Task.Delay(_outcomeSpecDraftTimeout, ct);

if (await Task.WhenAny(draftTask, timeoutTask).ConfigureAwait(false) == draftTask)
return await draftTask.ConfigureAwait(false);

ct.ThrowIfCancellationRequested();
var cancellationTask = draftCts.CancelAsync();
cleanupDeferred = true;
_ = ObserveTimedOutDraftAsync(draftTask, cancellationTask, draftCts, input.RunId);

_logger.LogError(
"Outcome-spec drafting timed out after {TimeoutSeconds}s for coordinator run {RunId}",
_outcomeSpecDraftTimeout.TotalSeconds,
input.RunId);
throw new CoordinatorOutcomeSpecDraftTimeoutException(
input.RunId,
_outcomeSpecDraftTimeout);
}
finally
{
if (!cleanupDeferred)
draftCts.Dispose();
}
}

private async Task ObserveTimedOutDraftAsync(
Task<OutcomeSpecDraft> draftTask,
Task cancellationTask,
CancellationTokenSource draftCts,
string runId)
{
try
{
await Task.WhenAll(
ObserveTimedOutDraftCompletionAsync(draftTask, draftCts, runId),
ObserveDraftCancellationAsync(cancellationTask, runId)).ConfigureAwait(false);
}
finally
{
draftCts.Dispose();
}
}

private async Task ObserveTimedOutDraftCompletionAsync(
Task<OutcomeSpecDraft> draftTask,
CancellationTokenSource draftCts,
string runId)
{
try
{
await draftTask.ConfigureAwait(false);
}
catch (OperationCanceledException) when (draftCts.IsCancellationRequested)
{
// Expected after the deadline requests cancellation.
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Outcome-spec drafter faulted after its timeout for coordinator run {RunId}",
runId);
}
}

private async Task ObserveDraftCancellationAsync(Task cancellationTask, string runId)
{
try
{
await cancellationTask.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Outcome-spec draft cancellation callback failed for coordinator run {RunId}",
runId);
}
}

/// <summary>
/// Loads the current persisted outcome spec for a run and projects it into an
/// <see cref="OutcomeSpecDraft"/> to seed a revision's invariant-preservation context (issue
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/__tests__/CoordinatorRunPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,23 @@ describe('CoordinatorRunPage — graph during outcome-plan drafting', () => {
expect(screen.queryByLabelText('Select Outcome plan: Pending')).toBeNull();
});

it('uses the durable run start while drafting so elapsed time does not remain at zero', async () => {
vi.setSystemTime(new Date('2026-07-07T00:02:05.000Z'));
vi.mocked(apiClient.getRun).mockResolvedValue({
run_id: 'coord-run-1',
status: 'in_progress',
coordinator_status: 'drafting',
started_at: '2026-07-07T00:01:00.000Z',
ended_at: null,
} as never);
vi.mocked(apiClient.getRunGraph).mockResolvedValue(COORDINATOR_GRAPH_DRAFTING_DESCRIPTOR);

render(<Wrapper><CoordinatorRunPage /></Wrapper>);

const progress = await screen.findByTestId('run-progress-chips', undefined, { timeout: 4000 });
expect(progress.textContent).toContain('1m 5s elapsed');
});

it('hides the assembly pipeline stages and shows a caption while drafting the spec', async () => {
// Drafting state: coordinator + planned assembly stages, no subtasks, no confirmed spec.
vi.mocked(apiClient.getRunGraph).mockResolvedValue(COORDINATOR_GRAPH_DRAFTING_DESCRIPTOR);
Expand Down
27 changes: 24 additions & 3 deletions apps/web/src/pages/CoordinatorRunPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,12 @@ function readEventTimestamp(p: Record<string, unknown>): string | undefined {
return readStr(p, ['timestamp_utc', 'timestampUtc', 'updated_at', 'updatedAt', 'timestamp']);
}

function parseTimestamp(value: string | null | undefined): number | undefined {
if (!value) return undefined;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : undefined;
}

function readChildRunId(node: GraphDescriptor['nodes'][number]): string | undefined {
return node.child_run_id
?? readStr(node.data ?? {}, ['child_run_id', 'childRunId'])
Expand Down Expand Up @@ -2264,6 +2270,13 @@ export function CoordinatorRunPage() {
const setRunLevelStatus = useCallback((status: RunStatus | undefined) => {
setRunLevelStatusState({ runId: runId ?? '', status });
}, [runId]);
const [runTimingState, setRunTimingState] = useState<{
runId: string;
startedAt: number | undefined;
endedAt: number | undefined;
}>({ runId: '', startedAt: undefined, endedAt: undefined });
const runStartedAt = runTimingState.runId === (runId ?? '') ? runTimingState.startedAt : undefined;
const runEndedAt = runTimingState.runId === (runId ?? '') ? runTimingState.endedAt : undefined;

const {
events,
Expand Down Expand Up @@ -2447,6 +2460,7 @@ export function CoordinatorRunPage() {
setWorkPlanError(null);
setNoWorkPlan(false);
setRunLevelStatus(undefined);
setRunTimingState({ runId: runId ?? '', startedAt: undefined, endedAt: undefined });
setCoordStatusField(undefined);
setCoordStatusReason(undefined);
setCoordinatorSteerable(undefined);
Expand Down Expand Up @@ -2513,6 +2527,11 @@ export function CoordinatorRunPage() {
setCoordinatorSteerable(typeof detail?.coordinator_steerable === 'boolean' ? detail.coordinator_steerable : undefined);
setWorkPlanStatus(wpStatus);
setRunLevelStatus(detail?.status ?? undefined);
setRunTimingState({
runId,
startedAt: parseTimestamp(detail?.started_at),
endedAt: parseTimestamp(detail?.ended_at),
});
if (wp) consecutiveWorkPlanNotReady = 0;
// Seed the option toggles once from the run detail; subsequent user toggles own the state.
if (!seededToggles.current && detail) {
Expand Down Expand Up @@ -3508,12 +3527,14 @@ export function CoordinatorRunPage() {
{ pending: 0, waiting: 0, blocked: 0, failed: 0 },
);
const hasRunningSessionItem = flatSessionTree.some((node) => node.startedAt !== undefined && node.completedAt === undefined);
const elapsedNow = useTickingNow(hasRunningSessionItem);
const earliestStart = flatSessionTree.reduce<number | undefined>(
const earliestNodeStart = flatSessionTree.reduce<number | undefined>(
(min, node) => (node.startedAt == null ? min : min == null ? node.startedAt : Math.min(min, node.startedAt)),
undefined,
);
const elapsedLabel = earliestStart ? fmtTotal(elapsedNow - earliestStart) : '0s';
const earliestStart = earliestNodeStart ?? runStartedAt;
const elapsedNow = useTickingNow(hasRunningSessionItem || (!viewState.terminal && earliestStart !== undefined));
const elapsedEnd = viewState.terminal ? (runEndedAt ?? elapsedNow) : elapsedNow;
const elapsedLabel = earliestStart ? fmtTotal(Math.max(0, elapsedEnd - earliestStart)) : '0s';
const runStatusText = viewState.label;
const taskCountsLabel = `${taskRows.length} tasks · ${taskStatusSummary.pending} pending · ${taskStatusSummary.waiting} waiting`;

Expand Down
14 changes: 14 additions & 0 deletions docs/deep-dive/coordinator-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,20 @@ re-arms the correct service. For `dispatching` plans it honors the distributed `
lease first, skipping freshly owned plans and stealing only stale ones. Each candidate is isolated
by try/catch so one corrupt plan does not stop the sweep.

### Bounded outcome-spec drafting

Outcome-spec drafting has its own wall-clock bound because it includes provider setup and session
creation before the normal streaming-turn watchdog begins. `Coordinator:OutcomeSpecDraftTimeoutSeconds`
defaults to **120 seconds** and must be greater than zero. When it expires, the coordinator cancels
the draft without waiting for provider cancellation callbacks, transitions the durable run to
`failed`, and emits `run.failed` with reason `outcome_spec_draft_timeout` instead of leaving the run
indefinitely in `drafting`. The abandoned draft and cancellation work remain observed, and their
linked cancellation source is disposed after both settle.

| Configuration key | Default | Effect |
|---|---:|---|
| `Coordinator:OutcomeSpecDraftTimeoutSeconds` | `120` | Maximum wall-clock time for provider setup, session creation, and the outcome-spec model turn before the coordinator fails with `outcome_spec_draft_timeout`; must be greater than zero. |

### Bounded final-Scribe recovery

At startup, recovery checks terminal coordinator runs for a missing final Scribe. It skips runs that
Expand Down
2 changes: 1 addition & 1 deletion packages/Agentweaver.AgentRuntime/CopilotAIAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@ internal static bool IsMissingCopilotAuth(Exception ex)
return providerFailure;
}

private void EmitProviderFailure(AgentProviderException providerFailure)
internal void EmitProviderFailure(AgentProviderException providerFailure)
{
Emit(EventTypes.RunFailed, new
{
Expand Down
Loading
Loading