Skip to content

Commit 71ab66e

Browse files
fix(autonomous): resume status on directive, idle caption guard, plan-mode time budget (#7290) (#7291)
1 parent efc431e commit 71ab66e

6 files changed

Lines changed: 111 additions & 25 deletions

File tree

openaev-api/src/main/java/io/openaev/service/autonomous/AutonomousRunService.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1609,6 +1609,18 @@ public AutonomousDirective addDirective(String runId, String content) {
16091609
"Operator directive queued",
16101610
content,
16111611
null);
1612+
// A queued directive answers whatever the run was parked on. If it was
1613+
// WAITING_INPUT, the operator has now responded, so leave that state right
1614+
// away instead of waiting for the next orchestrator cycle to flip it -
1615+
// otherwise the header keeps reading "Waiting for input" while the run has
1616+
// in fact resumed (the reported header/cockpit contradiction). A plan run
1617+
// returns to PLANNING (still authoring), a live run to RUNNING; the
1618+
// orchestrator refines this on its next cycle. Only WAITING_INPUT is
1619+
// touched, so a directive on an already-active run never perturbs it.
1620+
if (run.getStatus() == AutonomousRunStatus.WAITING_INPUT) {
1621+
run.setStatus(run.isPlanMode() ? AutonomousRunStatus.PLANNING : AutonomousRunStatus.RUNNING);
1622+
runRepository.save(run);
1623+
}
16121624
// Re-arm the orchestrator so it picks up the directive now, not only at its next scheduled
16131625
// re-check - crucial when the run is parked in WAITING_INPUT after asking the operator a
16141626
// question. Fired after commit so the orchestrator can never consume before the row is visible.
@@ -3370,6 +3382,16 @@ private AutonomousRun reconcileWithSimulation(AutonomousRun run) {
33703382
if (run.isPlanMode() || current == AutonomousRunStatus.WAITING_INPUT) {
33713383
return run;
33723384
}
3385+
// An unconsumed (PENDING) directive is proof-of-life, not a desync: the operator just
3386+
// steered - addDirective flips WAITING_INPUT to RUNNING right away - and the orchestrator
3387+
// has not picked the directive up yet, so the simulation legitimately still reads FINISHED
3388+
// underneath the now-RUNNING run. Completing here would kill the run inside that
3389+
// answer-to-consume window: the same premature termination the WAITING_INPUT guard above
3390+
// fixes, just shifted a few seconds later.
3391+
if (directiveRepository.existsByRunIdAndStatus(
3392+
run.getId(), AutonomousDirectiveStatus.PENDING)) {
3393+
return run;
3394+
}
33733395
target = AutonomousRunStatus.COMPLETED;
33743396
} else {
33753397
// Scheduled / running / paused: the simulation is still live, no desync to fix.

openaev-front/src/admin/components/autonomous/AutonomousReasoningPanel.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ import { AUTONOMOUS_PANEL_WIDTH } from './useAutonomousPanelWidth';
3434
// PLANNED is settled (logic done), so it is intentionally NOT active.
3535
const ACTIVE_STATUSES: AutonomousRunStatus[] = ['PLANNING', 'RUNNING', 'WAITING_INPUT'];
3636
const POLL_INTERVAL_MS = 3000;
37+
// A live ("active") caption keeps pulsing as if the orchestrator were computing right now. If the
38+
// newest timeline event is older than this, it is NOT: the run is parked between cycles, waiting on
39+
// a directive re-check, or an upstream cycle stalled. Past this age we settle any active caption
40+
// into a calm idle one so the cockpit never lies with a frozen "Deciding the next move" for minutes
41+
// (the reported stuck-cockpit symptom). A fresh event re-animates it. Sized above a normal cycle's
42+
// event cadence so an ordinary long action does not flip it to idle prematurely.
43+
const STALE_CAPTION_AFTER_MS = 180000;
3744

3845
// Cap the proposed one-click choices so the callout stays scannable: at most this many radio options
3946
// are ever shown, and the always-present free-text composer below is the escape hatch for anything
@@ -630,7 +637,14 @@ const AutonomousReasoningPanel: FunctionComponent<AutonomousReasoningPanelProps>
630637
// ... the AI is demonstrably working again, so the caption must narrate THAT instead of freezing
631638
// on "Processing your answer" (the exact "it says processing while it is already executing" bug).
632639
const newestIsActivity = isActivityType(newestEvent?.autonomous_event_type);
633-
const thinkingPhase: ThinkingPhase = (() => {
640+
// How long since the newest event? A stale timeline means the orchestrator is not actively
641+
// working, whatever the last event type was - used below to stop an active caption pulsing over a
642+
// frozen cockpit.
643+
const newestEventAgeMs = newestEvent?.autonomous_event_created_at
644+
? Date.now() - new Date(newestEvent.autonomous_event_created_at).getTime()
645+
: 0;
646+
const captionStale = newestEventAgeMs > STALE_CAPTION_AFTER_MS;
647+
let thinkingPhase: ThinkingPhase = (() => {
634648
if (isWaitingInput && pendingQuestion) {
635649
// Genuinely idle on the operator: static wait, not a pulsing "still working" animation.
636650
return {
@@ -745,6 +759,19 @@ const AutonomousReasoningPanel: FunctionComponent<AutonomousReasoningPanelProps>
745759
};
746760
}
747761
})();
762+
// Staleness backstop: never keep an active caption pulsing over a timeline that has not moved in
763+
// minutes. Settle it into a calm idle caption that tells the truth (still awaiting the operator,
764+
// or simply parked between moves). Static captions (open question, end-of-cycle park) already
765+
// reflect an idle state, so leave them untouched. A newer event clears captionStale and the live
766+
// caption resumes on the next 3s poll.
767+
if (captionStale && thinkingPhase.active) {
768+
thinkingPhase = {
769+
key: 'idle',
770+
label: isWaitingInput ? t('Waiting for your input') : t('Awaiting the next event'),
771+
color: theme.palette.text.secondary,
772+
active: false,
773+
};
774+
}
748775

749776
return (
750777
<Box

openaev-front/src/admin/components/autonomous/AutonomousRunConfigDrawer.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ interface AutonomousRunConfigDrawerProps {
2525
/** Default time budget (hours) when nothing pre-fills it: 24h for a live launch, a smaller value
2626
* for the AI builder (planning is a quick, untimed design pass). Omit to keep the 24h default. */
2727
defaultTimeoutHours?: number;
28+
/** The drawer hosts the AI builder (a plan-authoring pass), not a live launch. Plan mode is
29+
* untimed server-side, so the time budget always shows the default and is omitted from the
30+
* payload (never persisted as a stale value). Defaults to false (live launch). */
31+
planMode?: boolean;
2832
submitting?: boolean;
2933
error?: string | null;
3034
/** Show the "Save for later" action (persist config, start nothing). Defaults to false. */
@@ -53,6 +57,7 @@ const AutonomousRunConfigDrawer = ({
5357
defaultObjective,
5458
demoteTemplates,
5559
defaultTimeoutHours,
60+
planMode,
5661
submitting = false,
5762
error,
5863
showSave = false,
@@ -69,6 +74,7 @@ const AutonomousRunConfigDrawer = ({
6974
initialInput,
7075
defaultObjective,
7176
defaultTimeoutHours,
77+
isPlanMode: planMode,
7278
});
7379

7480
// Clear the selection every time the drawer closes so a fresh open starts clean (and a preset

openaev-front/src/admin/components/autonomous/useAutonomousRunConfig.ts

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ export interface UseAutonomousRunConfigOptions {
144144
* (from {@link initialInput}) still wins.
145145
*/
146146
defaultTimeoutHours?: number;
147+
/**
148+
* The host is the AI builder (a build / plan-authoring pass), not a live launch. Plan mode is
149+
* untimed server-side, so the time budget is meaningless here: we always show {@link
150+
* defaultTimeoutHours} (ignoring any timeout a previously-saved build config carried) and omit
151+
* `timeout_seconds` from the built payload so a stale value can never be persisted and surfaced
152+
* back as a misleading budget on the next open.
153+
*/
154+
isPlanMode?: boolean;
147155
}
148156

149157
/** Fallback time budget (hours) when a host does not override it: autonomous execution is long-lived. */
@@ -194,6 +202,7 @@ export const useAutonomousRunConfig = ({
194202
initialInput,
195203
defaultObjective,
196204
defaultTimeoutHours = DEFAULT_TIMEOUT_HOURS,
205+
isPlanMode = false,
197206
}: UseAutonomousRunConfigOptions): AutonomousRunConfig => {
198207
const [templates, setTemplates] = useState<AutonomousObjectiveTemplate[]>([]);
199208
const [loadingTemplates, setLoadingTemplates] = useState(false);
@@ -284,9 +293,10 @@ export const useAutonomousRunConfig = ({
284293
setName(initialInput.name ?? '');
285294
setDescription(initialInput.description ?? '');
286295
// A saved timeout wins; otherwise fall back to this host's default (24h launch / 1h planning)
287-
// rather than leaving whatever a previous open left behind.
296+
// rather than leaving whatever a previous open left behind. In plan mode the budget is
297+
// server-untimed and meaningless, so always show the default (never a stale saved value).
288298
setTimeoutHours(
289-
initialInput.timeout_seconds && initialInput.timeout_seconds > 0
299+
!isPlanMode && initialInput.timeout_seconds && initialInput.timeout_seconds > 0
290300
? Math.round(initialInput.timeout_seconds / 3600)
291301
: defaultTimeoutHours,
292302
);
@@ -302,7 +312,7 @@ export const useAutonomousRunConfig = ({
302312
setObjective(defaultObjective);
303313
}
304314
}
305-
}, [open, initialInput, defaultObjective, defaultTimeoutHours]);
315+
}, [open, initialInput, defaultObjective, defaultTimeoutHours, isPlanMode]);
306316

307317
const selectTemplate = (template: AutonomousObjectiveTemplate) => {
308318
setSelectedTemplateKey(template.autonomous_objective_template_key);
@@ -363,27 +373,39 @@ export const useAutonomousRunConfig = ({
363373
setAgentsLoaded(false);
364374
};
365375

366-
const buildInput = (planMode = false): AutonomousRunCreateInput => ({
367-
objective: objective.trim(),
368-
objective_template_key: selectedTemplateKey ?? undefined,
369-
name: name.trim() || undefined,
370-
description: description.trim() || undefined,
371-
scope_rules: scopeRules.length > 0 ? scopeRules : undefined,
372-
// Authoritative selection: send it as soon as the agents loaded (even empty, i.e. the operator
373-
// disabled every agent). Only omit it when the fetch failed, so the backend can fall back to
374-
// the tenant defaults rather than silently running with no specialist agents.
375-
agent_ids: agentsLoaded ? selectedAgentIds : undefined,
376-
agent_modes: agentsLoaded
377-
? {
378-
[ORCHESTRATOR_AGENT_ID]: selectedAgentModes[ORCHESTRATOR_AGENT_ID] ?? ORCHESTRATOR_DEFAULT_DISCOVERY_MODE,
379-
...Object.fromEntries(selectedAgentIds.map(id => [id, selectedAgentModes[id] ?? SPECIALIST_DEFAULT_DISCOVERY_MODE])),
380-
}
381-
: undefined,
382-
plan_mode: planMode || undefined,
383-
// OpenAEV-enforced run deadline (seconds). Clamp to the advertised 1h-720h range (the HTML
384-
// min/max only guard the spinner, not typed input); ignored server-side in build mode.
385-
timeout_seconds: Math.min(720 * 3600, Math.max(3600, Math.round(timeoutHours * 3600))),
386-
});
376+
const buildInput = (planMode = false): AutonomousRunCreateInput => {
377+
// A plan-mode HOST builds a plan payload whatever action was pressed: the AI builder's primary
378+
// "Build" action goes through the generic launch callback (buildInput(false)), so keying off
379+
// the per-call argument alone would still carry timeout_seconds (which the build flow persists
380+
// as the scenario's saved config) and omit plan_mode. A live-launch host (isPlanMode false)
381+
// keeps the per-call argument as the sole switch.
382+
const effectivePlanMode = isPlanMode || planMode;
383+
return {
384+
objective: objective.trim(),
385+
objective_template_key: selectedTemplateKey ?? undefined,
386+
name: name.trim() || undefined,
387+
description: description.trim() || undefined,
388+
scope_rules: scopeRules.length > 0 ? scopeRules : undefined,
389+
// Authoritative selection: send it as soon as the agents loaded (even empty, i.e. the operator
390+
// disabled every agent). Only omit it when the fetch failed, so the backend can fall back to
391+
// the tenant defaults rather than silently running with no specialist agents.
392+
agent_ids: agentsLoaded ? selectedAgentIds : undefined,
393+
agent_modes: agentsLoaded
394+
? {
395+
[ORCHESTRATOR_AGENT_ID]: selectedAgentModes[ORCHESTRATOR_AGENT_ID] ?? ORCHESTRATOR_DEFAULT_DISCOVERY_MODE,
396+
...Object.fromEntries(selectedAgentIds.map(id => [id, selectedAgentModes[id] ?? SPECIALIST_DEFAULT_DISCOVERY_MODE])),
397+
}
398+
: undefined,
399+
plan_mode: effectivePlanMode || undefined,
400+
// OpenAEV-enforced run deadline (seconds). Clamp to the advertised 1h-720h range (the HTML
401+
// min/max only guard the spinner, not typed input). Omitted entirely in build/plan mode: the
402+
// server does not enforce a deadline while planning, and persisting one would carry a stale,
403+
// misleading budget back into the next open of the saved config.
404+
timeout_seconds: effectivePlanMode
405+
? undefined
406+
: Math.min(720 * 3600, Math.max(3600, Math.round(timeoutHours * 3600))),
407+
};
408+
};
387409

388410
return {
389411
templates,

openaev-front/src/admin/components/scenarios/scenario/ScenarioHeader.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,7 @@ const ScenarioHeader = ({
911911
// budget to 1h rather than surfacing the misleading 24h execution budget. A live autonomous
912912
// launch keeps the 24h default (recon + human-in-the-loop steps make it long-lived).
913913
defaultTimeoutHours={aiDrawerIntent === 'build' ? 1 : undefined}
914+
planMode={aiDrawerIntent === 'build'}
914915
title={aiDrawerTitle}
915916
infoText={aiDrawerIntent === 'build'
916917
? t('Let the AI build this scenario\'s logic for you - set the objective, the specialist agents the orchestrator may consult, and the scope. Save it to build or launch later, or Build now to have the orchestrator author the steps onto the scenario. Nothing runs while building; you launch the scenario afterwards, in normal or autonomous mode.')

openaev-model/src/main/java/io/openaev/database/repository/autonomous/AutonomousDirectiveRepository.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ List<AutonomousDirective> findByRunIdAndStatusOrderByCreatedAtAsc(
1818

1919
List<AutonomousDirective> findByRunIdOrderByCreatedAtAsc(String runId);
2020

21+
/**
22+
* Cheap existence probe: does the run still have a directive in the given status? Used by the
23+
* run/simulation reconciliation as a proof-of-life signal - a PENDING directive means the
24+
* operator just steered and the orchestrator has not consumed it yet, so the run must not be
25+
* settled from a stale simulation status.
26+
*/
27+
boolean existsByRunIdAndStatus(String runId, AutonomousDirectiveStatus status);
28+
2129
/** Bulk-purges a run's steering directives when the run is deleted. */
2230
@Modifying
2331
@Query("DELETE FROM AutonomousDirective d WHERE d.runId = :runId")

0 commit comments

Comments
 (0)