fix(agent): review fixes — deployable-template gating, stopped-status guard, UI consistency

Backend:
- loadAgentTemplate now requires status='active' — a known template_key can no
  longer deploy a template an admin deactivated (matches the client list).
- refreshAgentStatus no longer lets AM's eventually-consistent live status
  resurrect a user-initiated "stopped" agent.
- HeicodeStopAgent persists via field-level Updates (not a stale full-row Save),
  matching refreshAgentStatus discipline.
- Drop dead amStartResult.AccessToken field (AM's token is never used; HM mints
  its own per-agent token).

Frontend:
- deploy-agent statusLabel: add the missing pending/starting → 启动中 branch so a
  just-deployed agent isn't shown as raw English fallback.
- cockpit 最近部署: map template_id → Chinese template name (consistent with the
  deploy/status pages) instead of showing the raw key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 11:06:49 +08:00
co-authored by Claude Opus 4.8
parent c05b27de6a
commit f4968b8072
5 changed files with 47 additions and 14 deletions
+15 -3
View File
@@ -269,6 +269,12 @@ func refreshAgentStatus(c *gin.Context, row *model.AgentDeployment) {
if strings.TrimSpace(row.RuntimeDeploymentID) == "" {
return
}
// A user-initiated "stopped" is a terminal intent: don't let AM's eventually-
// consistent live status (which may still report running/pending while the pod
// drains) resurrect it. Only delete (which removes the row) leaves "stopped".
if strings.EqualFold(strings.TrimSpace(row.Status), "stopped") {
return
}
status, err := amGetAgentStatus(c.Request.Context(), row.RuntimeDeploymentID)
if err != nil || strings.TrimSpace(status) == "" || status == row.Status {
return
@@ -324,10 +330,16 @@ func HeicodeStopAgent(c *gin.Context) {
return
}
}
now := agentNow()
nowMs := time.Now().UnixMilli()
row.Status = "stopped"
row.UpdatedAtText = agentNow()
row.UpdatedAtMs = time.Now().UnixMilli()
if err := model.DB.Save(&row).Error; err != nil {
row.UpdatedAtText = now
row.UpdatedAtMs = nowMs
// Field-level update (not a full-row Save of a possibly-stale snapshot) — same
// discipline as refreshAgentStatus, so concurrent column writes aren't clobbered.
if err := model.DB.Model(&model.AgentDeployment{}).
Where("deployment_id = ?", row.DeploymentID).
Updates(map[string]any{"status": "stopped", "updated_at_text": now, "updated_at_ms": nowMs}).Error; err != nil {
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist stop")
return
}
+4 -2
View File
@@ -129,14 +129,16 @@ func ensureAgentTemplatesSeeded() {
})
}
// loadAgentTemplate fetches an active template by its key.
// loadAgentTemplate fetches an active template by its key. Deactivated templates
// (status != "active") are NOT deployable — same visibility as the client list,
// so a known template_key can't be used to deploy a template an admin took down.
func loadAgentTemplate(key string) (model.AgentTemplate, bool) {
ensureAgentTemplatesSeeded()
var row model.AgentTemplate
if model.DB == nil || strings.TrimSpace(key) == "" {
return row, false
}
if err := model.DB.Where("template_key = ?", strings.TrimSpace(key)).First(&row).Error; err != nil {
if err := model.DB.Where("template_key = ? AND status = ?", strings.TrimSpace(key), "active").First(&row).Error; err != nil {
return row, false
}
return row, true
+9 -8
View File
@@ -47,11 +47,13 @@ func publicV1BaseURL() string {
}
// amStartResult is what AM returns after starting a template agent.
// Note: AM's own access_token is intentionally NOT captured here — HM mints its
// own per-agent access token (see HeicodeDeployAgent) and that is what gates the
// client↔agent connection.
type amStartResult struct {
RuntimeID string
Subdomain string
AccessToken string
Status string
RuntimeID string
Subdomain string
Status string
}
func agentTemplateStartPath() string {
@@ -199,10 +201,9 @@ func amStartTemplateAgent(ctx context.Context, args amStartArgs) (amStartResult,
}
}
return amStartResult{
RuntimeID: firstNonEmpty(stringFromMap(data, "runtime_id", "agent_id", "id", "name", "namespace"), name),
Subdomain: subdomain,
AccessToken: stringFromMap(data, "access_token", "token"),
Status: firstNonEmpty(stringFromMap(data, "status", "runtime_status"), "running"),
RuntimeID: firstNonEmpty(stringFromMap(data, "runtime_id", "agent_id", "id", "name", "namespace"), name),
Subdomain: subdomain,
Status: firstNonEmpty(stringFromMap(data, "status", "runtime_status"), "running"),
}, nil
}
@@ -33,6 +33,14 @@ async function listMyAgents(): Promise<AgentItem[]> {
return res.data?.data?.items ?? []
}
type TemplateItem = { template_id: string; name: string }
async function listTemplates(): Promise<TemplateItem[]> {
const res = await api.get<{ data?: { items?: TemplateItem[] } }>(
'/api/heicode/agent-templates'
)
return res.data?.data?.items ?? []
}
// Current user's OWN balance/usage (NOT the admin-global aggregate) — same
// source the 模型与余额 page uses.
async function getSelfUsage(): Promise<UserWalletData> {
@@ -171,6 +179,14 @@ export function CockpitView() {
retry: false,
})
const templatesQuery = useQuery({
queryKey: ['agent-templates'],
queryFn: listTemplates,
retry: false,
})
const templateName = (key: string) =>
(templatesQuery.data ?? []).find((t) => t.template_id === key)?.name ?? key
const stats = useMemo(() => {
const list: AgentItem[] = deploymentsQuery.data ?? []
let running = 0
@@ -286,7 +302,7 @@ export function CockpitView() {
<div className='flex items-center gap-2'>
<Rocket className='h-3.5 w-3.5 text-primary' />
<span className='truncate text-sm font-medium'>
{dep.template_id}
{templateName(dep.template_id)}
</span>
<PhasePill phase={dep.status} />
</div>
@@ -61,6 +61,8 @@ function statusLabel(s: string): { text: string; cls: string } {
const v = (s || '').toLowerCase()
if (['running', 'active', 'ready'].includes(v))
return { text: '运行中', cls: 'bg-emerald-500/15 text-emerald-300 ring-emerald-500/30' }
if (['pending', 'starting', 'provisioning'].includes(v))
return { text: '启动中', cls: 'bg-amber-500/15 text-amber-300 ring-amber-500/30' }
if (['stopped'].includes(v))
return { text: '已停止', cls: 'bg-muted/40 text-muted-foreground ring-border' }
if (['failed', 'error', 'unhealthy', 'crashed'].includes(v))