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
3 changes: 3 additions & 0 deletions .specify/feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"feature_directory": "specs/001-stabilize-agent-startup"
}
2 changes: 2 additions & 0 deletions admin/slices/agent/agent/components/agent/chat/Tab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ watch(
:title="`Chat with ${agent.name}`"
:restart-prompt="false"
:agent-state="bridleAgentState"
:initial-debug-enabled="agent.debugEnabled"
class="h-full w-full gap-0"
/>
<Transition
Expand Down Expand Up @@ -156,6 +157,7 @@ watch(
:agent-id="agent.id"
closable
:restarting="restartUnderway"
:first-start="agent.launchContext === 'initial'"
class="h-full min-w-100"
@close="showSideLogs = false"
/>
Expand Down
14 changes: 12 additions & 2 deletions admin/slices/agent/agent/components/agent/logs/Panel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ const props = defineProps<{
// (ContainerCreating 400s and the like). An overlay says what's actually
// happening instead of surfacing that noise.
restarting?: boolean;
// First-ever start of this agent (server-derived launchContext='initial'):
// the overlay reads "setting up" instead of "restarting", so a fresh deploy
// doesn't look like an update of something that already existed.
firstStart?: boolean;
}>();

const emit = defineEmits<{ close: [] }>();
Expand Down Expand Up @@ -89,9 +93,15 @@ const LOG_LEVEL_TEXT: Record<AgentLogLevel, string> = {
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-background/70 backdrop-blur-[2px]"
>
<IconLoader2 class="size-6 animate-spin text-primary" />
<span class="text-sm font-medium">Agent is restarting…</span>
<span class="text-sm font-medium">
{{ firstStart ? 'Setting up agent…' : 'Agent is restarting…' }}
</span>
<span class="text-xs text-muted-foreground">
Logs will resume when the new pod is up.
{{
firstStart
? 'First start — logs will appear once the agent’s pod is up.'
: 'Logs will resume when the new pod is up.'
}}
</span>
</div>
</Transition>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ const podLabel = computed(() => podPhaseLabel(podStatus.value));
· restarts {{ podStatus.restartCount }}</span>
</span>
</dd>
<p
v-if="agent.status === 'failed' && agent.statusReason"
class="mt-1 text-xs text-destructive"
>
{{ agent.statusReason }}
</p>
</div>
<div>
<dt class="text-xs text-muted-foreground">Visibility</dt>
Expand Down
42 changes: 32 additions & 10 deletions admin/slices/agent/agent/composables/useAgentLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,19 @@ export function useAgentLifecycle(
// Each refresh replaces the agent ref, which re-runs the computeds (fresh
// Date.now() → TTL honored) and gives the server a chance to reconcile.
let statusTimer: ReturnType<typeof setInterval> | null = null;
// While a lifecycle mutation (restart/stop/start) is awaiting its HTTP
// response, a poll tick can resolve with the PRE-mutation status and
// overwrite the optimistic 'deploying'/'stopped' flip wholesale. Skip
// ticks for that window — the mutation handlers refresh() on completion.
const pollTick = () => {
if (restarting.value || toggling.value) return;
void refresh();
};
watch(
() => [agent.value?.status, agentStore.isRestartInFlight(agentId)] as const,
([status, inFlight]) => {
if ((status && POLL_STATUSES.has(status)) || inFlight) {
if (!statusTimer) statusTimer = setInterval(refresh, 5000);
if (!statusTimer) statusTimer = setInterval(pollTick, 5000);
} else if (statusTimer) {
clearInterval(statusTimer);
statusTimer = null;
Expand Down Expand Up @@ -189,22 +197,25 @@ export function useAgentLifecycle(
// Pod info only describes the FRESH pod; while the old one is still
// being torn down it would misleadingly read "Ready".
const freshPod = pod && (agentWentDown.value || !inFlight) ? pod : null;
// Server-derived launch context: a first-ever start reads "setting up",
// anything else reads "starting" — so a fresh deploy no longer looks
// like an update of something that already existed.
const firstStart = agent.value.launchContext === 'initial';
return {
kind: 'starting',
title: 'Starting agent…',
title: firstStart ? 'Setting up agent…' : 'Starting agent…',
detail: freshPod
? `Pod ${freshPod.podName}: ${podLabel.value ?? freshPod.phase}`
: 'Cancelling old workflow and submitting a fresh one.',
: firstStart
? 'First start — preparing the agent’s pod.'
: 'Cancelling old workflow and submitting a fresh one.',
};
}

// Strongest "agent is up" signal: chat WS is connected AND the runtime is
// registered with the hub. This bypasses DB/pod entirely — if the agent
// is actually talking to us, nothing else matters.
const chatLive = bridleStore.isConnected && bridleStore.isAgentConnected;

if (chatLive) return null;

// An explicit stop wins over the live-chat bypass below: the old
// runtime's WS can linger after the pod delete (indefinitely on local
// dev, where there is no pod to kill) — reading that as "all good" hid
// the stopped state from the user entirely.
if (s === 'stopped') {
return {
kind: 'stopped',
Expand All @@ -214,11 +225,22 @@ export function useAgentLifecycle(
};
}

// Strongest "agent is up" signal: chat WS is connected AND the runtime is
// registered with the hub. This bypasses DB/pod entirely — if the agent
// is actually talking to us, nothing else matters.
const chatLive = bridleStore.isConnected && bridleStore.isAgentConnected;

if (chatLive) return null;

if (s === 'failed') {
return {
kind: 'failed',
title: 'Agent failed to start',
// Server-side statusReason is the authoritative cause (startup
// timeout, ImagePullBackOff, workflow submit error, …); live pod
// details are the fallback for failures recorded before it existed.
detail:
agent.value.statusReason ??
pod?.message ??
pod?.containerWaitingReason ??
'Pod did not come up. Check logs and restart.',
Expand Down
17 changes: 17 additions & 0 deletions admin/slices/agent/agent/data/agent.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
IClusterCapacityData,
ICreateAgentData,
IUpdateAgentData,
LaunchContextTypes,
} from '../domain/agent.types';

const KNOWN_STATUSES = new Set<AgentStatusTypes>([
Expand All @@ -21,6 +22,11 @@ const KNOWN_STATUSES = new Set<AgentStatusTypes>([
'stopped',
]);

const KNOWN_LAUNCH_CONTEXTS = new Set<LaunchContextTypes>([
'initial',
'restart',
]);

function num(value: unknown): number {
return typeof value === 'number' ? value : 0;
}
Expand Down Expand Up @@ -50,7 +56,11 @@ export class AgentMapper {
llmCredentialId:
typeof o.llmCredentialId === 'string' ? o.llmCredentialId : null,
status: this.toStatus(o.status),
statusReason: typeof o.statusReason === 'string' ? o.statusReason : null,
workflowId: typeof o.workflowId === 'string' ? o.workflowId : null,
firstDeployedAt:
typeof o.firstDeployedAt === 'string' ? o.firstDeployedAt : null,
launchContext: this.toLaunchContext(o.launchContext),
config:
o.config && typeof o.config === 'object'
? (o.config as Record<string, unknown>)
Expand Down Expand Up @@ -137,6 +147,13 @@ export class AgentMapper {
: 'pending';
}

private toLaunchContext(raw: unknown): LaunchContextTypes | null {
return typeof raw === 'string' &&
KNOWN_LAUNCH_CONTEXTS.has(raw as LaunchContextTypes)
? (raw as LaunchContextTypes)
: null;
}

private toResources(raw: unknown): IAgentResources {
const r = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
return {
Expand Down
10 changes: 10 additions & 0 deletions admin/slices/agent/agent/domain/agent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export type AgentStatusTypes =
| 'failed'
| 'stopped';

/** Why the current/last deploy ran — server-derived, so the UI can tell a
* first start from a restart even after a page reload. */
export type LaunchContextTypes = 'initial' | 'restart';

export interface IAgentResources {
cpu: string;
memory: string;
Expand Down Expand Up @@ -49,7 +53,13 @@ export interface IAgentData {
templateId: string;
llmCredentialId: string | null;
status: AgentStatusTypes;
/** Human-readable failure cause; non-null only while status is 'failed'
* (may still be null for failures recorded before the field existed). */
statusReason: string | null;
workflowId: string | null;
/** Null ⇒ the agent has never been deployed. */
firstDeployedAt: string | null;
launchContext: LaunchContextTypes | null;
config: Record<string, unknown>;
resources: IAgentResources;
isPublic: boolean;
Expand Down
16 changes: 16 additions & 0 deletions admin/slices/agent/agent/utils/agentLogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,28 @@ const ERROR_TOKEN_RE =
const WARN_TOKEN_RE =
/(?:^|[[("']|·\s*|\blevel[=:]\s*"?)\s*warn(?:ing)?(?=[\s:\])"',]|$)/i;

// The runtime's pretty format opens every line with an explicit level glyph
// (logger.ts GLYPH map: `·` debug/info, `✓` ok, `⚠` warn, `✗` error). When
// present it is AUTHORITATIVE — a ⚠ warn line whose body happens to contain
// `"error":"Not Found"` (a quoted JSON payload) must not be escalated to
// error by the substring heuristics above. Optional ANSI wrappers tolerated
// for FORCE_COLOR runs.
// The optional leading `HH:MM:SS.mmm` covers undated lines where the runtime's
// own time prefix wasn't stripped (no K8s timestamp on the line).
const GLYPH_LEVEL_RE =
// eslint-disable-next-line no-control-regex
/^\s*(?:\d{2}:\d{2}:\d{2}\.\d{3}\s+)?(?:\x1b\[[0-9;]*m)?([·✓⚠✗])(?:\x1b\[[0-9;]*m)?\s/u;

// The runtime prefixes its own `HH:MM:SS.mmm ` to every line. When the K8s
// timestamp already gives us the time column, that prefix is pure duplication
// — strip it (the column carries milliseconds, so no precision is lost).
const RUNTIME_TIME_PREFIX_RE = /^\d{2}:\d{2}:\d{2}\.\d{3}\s+/;

export function detectLogLevel(text: string): AgentLogLevel | null {
const glyph = text.match(GLYPH_LEVEL_RE)?.[1];
if (glyph === '✗') return 'error';
if (glyph === '⚠') return 'warn';
if (glyph === '·' || glyph === '✓') return null;
if (ERROR_UPPER_RE.test(text) || ERROR_TOKEN_RE.test(text)) return 'error';
if (WARN_UPPER_RE.test(text) || WARN_TOKEN_RE.test(text)) return 'warn';
return null;
Expand Down
20 changes: 16 additions & 4 deletions admin/slices/bridle/components/bridle/Provider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,17 @@ const props = withDefaults(defineProps<{
// page a failed agent reads as "reconnecting" for 30s. Hosts that know the
// real state (the admin agent page) pass it here; null = derive from WS.
agentState?: 'restarting' | 'failed' | 'stopped' | null
// Host-supplied debugEnabled from an agent record the host already fetched.
// When set (non-null), the widget skips its own GET /agents/:id — the admin
// agent page otherwise loads the same agent twice on every open.
initialDebugEnabled?: boolean | null
}>(), {
title: 'Agent Chat',
placeholder: 'Type a message...',
showStatus: true,
restartPrompt: true,
agentState: null,
initialDebugEnabled: null,
})

const store = useBridleStore()
Expand Down Expand Up @@ -229,10 +234,17 @@ onMounted(async () => {
// Clear before load so the previous agent's messages don't briefly leak
// through (the store is a shared singleton across providers).
store.clearMessages()
await Promise.all([
store.loadTranscript(props.apiUrl, props.agentId, props.token),
store.loadAgentMeta(props.apiUrl, props.agentId, props.token),
])
// The host may already hold the agent record (admin page useAsyncData) —
// seeding from the prop avoids a duplicate GET /agents/:id on every open.
if (props.initialDebugEnabled !== null) {
store.debugEnabled = props.initialDebugEnabled
await store.loadTranscript(props.apiUrl, props.agentId, props.token)
} else {
await Promise.all([
store.loadTranscript(props.apiUrl, props.agentId, props.token),
store.loadAgentMeta(props.apiUrl, props.agentId, props.token),
])
}
// Re-attach debug snapshots saved in localStorage from previous sessions —
// makes the inspect icon survive a page refresh.
store.loadPersistedDebug(props.agentId)
Expand Down
Loading
Loading