fix(agent): 部署 agent 改异步,避免网关 504 + 回滚
POST /api/heicode/agents 原本在请求里同步阻塞 ~30s 等 AM 起 agent。Azure 网关 ~20s 超时 → 504 → 请求 context 被取消 → AM 调用中止 → 部署回滚(agent 建不出)。 改为:先把 agent 存为 Pending 立即返回;AM 启动放到 context.Background() 的后台 goroutine(脱离请求 context),成功回填 subdomain/runtime_id/status,失败标记 status=failed 并吊销已铸的模型 key。客户端经已合并的列表刷新看 Pending→running。 go build ./... 通过;controller vet 干净。 影响面:仅 Manager(HM) 部署路径。客户端契约:deploy 现在立即返回 Pending(原为 阻塞后 running 或 504);AM 失败在列表里表现为 status=failed(原为同步 RUNTIME_UNAVAILABLE)。客户端本就轮询列表等 running。不改计费/密钥/审计。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -177,8 +177,36 @@ func HeicodeDeployAgent(c *gin.Context) {
|
||||
env["AGENT_ACCESS_TOKEN"] = agentAccessToken
|
||||
env["HEICODE_AGENT_ID"] = deploymentID
|
||||
|
||||
// Ask AM to start the agent with the template definition (.md) + env injected.
|
||||
result, err := amStartTemplateAgent(c.Request.Context(), amStartArgs{
|
||||
// Persist the agent as Pending and return immediately; start it in AM in the
|
||||
// background. Starting an agent in AM takes ~30s — longer than the Azure
|
||||
// gateway's ~20s timeout — so doing it inside the request returned 504, which
|
||||
// cancelled the request context, aborted the AM call and rolled the deploy
|
||||
// back. Now the client gets the Pending agent at once and polls
|
||||
// GET /api/heicode/agents (which refreshes live status from AM), seeing
|
||||
// Pending -> running once AM is up (or -> failed if AM start failed).
|
||||
now := agentNow()
|
||||
nowMs := time.Now().UnixMilli()
|
||||
row := model.AgentDeployment{
|
||||
DeploymentID: deploymentID,
|
||||
UserID: strconv.Itoa(userID),
|
||||
TemplateID: req.TemplateID,
|
||||
AccessToken: sealAgentToken(agentAccessToken),
|
||||
BindingIDsJSON: string(bindingIDsJSON),
|
||||
Status: "Pending",
|
||||
ModelTokenID: modelTokenID,
|
||||
CreatedAtText: now,
|
||||
UpdatedAtText: now,
|
||||
CreatedAtMs: nowMs,
|
||||
UpdatedAtMs: nowMs,
|
||||
}
|
||||
if err := model.DB.Create(&row).Error; err != nil {
|
||||
common.SysLog("HeicodeDeployAgent persist: " + err.Error())
|
||||
revokeAgentModelToken(modelTokenID)
|
||||
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist agent")
|
||||
return
|
||||
}
|
||||
|
||||
startTemplateAgentAsync(deploymentID, modelTokenID, amStartArgs{
|
||||
ManagerDeploymentID: deploymentID,
|
||||
UserID: strconv.Itoa(userID),
|
||||
TemplateKey: tpl.TemplateKey,
|
||||
@@ -187,43 +215,64 @@ func HeicodeDeployAgent(c *gin.Context) {
|
||||
Env: env,
|
||||
CallbackURL: agentRuntimeCallbackURL(),
|
||||
})
|
||||
if err != nil {
|
||||
revokeAgentModelToken(modelTokenID) // don't leak the minted key
|
||||
agentError(c, "RUNTIME_UNAVAILABLE", "failed to start agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
now := agentNow()
|
||||
nowMs := time.Now().UnixMilli()
|
||||
row := model.AgentDeployment{
|
||||
DeploymentID: deploymentID,
|
||||
UserID: strconv.Itoa(userID),
|
||||
TemplateID: req.TemplateID,
|
||||
Subdomain: result.Subdomain,
|
||||
AccessToken: sealAgentToken(agentAccessToken),
|
||||
BindingIDsJSON: string(bindingIDsJSON),
|
||||
RuntimeDeploymentID: result.RuntimeID,
|
||||
Status: firstNonEmpty(result.Status, "running"),
|
||||
ModelTokenID: modelTokenID,
|
||||
CreatedAtText: now,
|
||||
UpdatedAtText: now,
|
||||
CreatedAtMs: nowMs,
|
||||
UpdatedAtMs: nowMs,
|
||||
}
|
||||
if err := model.DB.Create(&row).Error; err != nil {
|
||||
common.SysLog("HeicodeDeployAgent persist: " + err.Error())
|
||||
revokeAgentModelToken(modelTokenID)
|
||||
// We started an agent in AM but failed to record it: roll back the
|
||||
// orphan so it does not leak/keep running with no Manager record.
|
||||
if strings.TrimSpace(result.RuntimeID) != "" {
|
||||
if delErr := amDeleteTemplateAgent(c.Request.Context(), result.RuntimeID); delErr != nil {
|
||||
common.SysLog("HeicodeDeployAgent orphan cleanup failed: " + delErr.Error())
|
||||
common.ApiSuccess(c, templateAgentResponse(row))
|
||||
}
|
||||
|
||||
// startTemplateAgentAsync starts the template agent in AM off the request path
|
||||
// and records the outcome. The deploy handler returns the Pending agent at once
|
||||
// so it never blocks ~30s on AM (which exceeded the Azure gateway timeout → 504
|
||||
// + rollback). Uses context.Background() because the request context is gone
|
||||
// once the handler returned; the AM HTTP call is still bounded by amTemplateDo's
|
||||
// own client timeout. The client observes Pending -> running (or -> failed) by
|
||||
// polling the agent list, which refreshes live status from AM.
|
||||
func startTemplateAgentAsync(deploymentID string, modelTokenID int, args amStartArgs) {
|
||||
go func() {
|
||||
result, err := amStartTemplateAgent(context.Background(), args)
|
||||
if model.DB == nil {
|
||||
return
|
||||
}
|
||||
now := agentNow()
|
||||
nowMs := time.Now().UnixMilli()
|
||||
if err != nil {
|
||||
common.SysLog("startTemplateAgentAsync: AM start failed for " + deploymentID + ": " + err.Error())
|
||||
reason := err.Error()
|
||||
if len(reason) > 480 {
|
||||
reason = strings.ToValidUTF8(reason[:480], "")
|
||||
}
|
||||
// Mark failed and revoke the minted model key (the agent never started).
|
||||
_ = model.DB.Model(&model.AgentDeployment{}).
|
||||
Where("deployment_id = ?", deploymentID).
|
||||
Updates(map[string]any{
|
||||
"status": "failed",
|
||||
"failure_reason": reason,
|
||||
"updated_at_text": now,
|
||||
"updated_at_ms": nowMs,
|
||||
}).Error
|
||||
revokeAgentModelToken(modelTokenID)
|
||||
return
|
||||
}
|
||||
res := model.DB.Model(&model.AgentDeployment{}).
|
||||
Where("deployment_id = ?", deploymentID).
|
||||
Updates(map[string]any{
|
||||
"subdomain": result.Subdomain,
|
||||
"runtime_deployment_id": result.RuntimeID,
|
||||
"status": firstNonEmpty(result.Status, "running"),
|
||||
"updated_at_text": now,
|
||||
"updated_at_ms": nowMs,
|
||||
})
|
||||
if res.Error != nil {
|
||||
common.SysLog("startTemplateAgentAsync: persist result failed for " + deploymentID + ": " + res.Error.Error())
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 && strings.TrimSpace(result.RuntimeID) != "" {
|
||||
// Record was deleted (user cancelled) before AM finished starting — clean
|
||||
// up the orphan agent in AM so it doesn't keep running untracked.
|
||||
if delErr := amDeleteTemplateAgent(context.Background(), result.RuntimeID); delErr != nil {
|
||||
common.SysLog("startTemplateAgentAsync orphan cleanup failed: " + delErr.Error())
|
||||
}
|
||||
}
|
||||
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist agent")
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, templateAgentResponse(row))
|
||||
}()
|
||||
}
|
||||
|
||||
// findUserTemplateAgent loads a deployed template agent owned by the caller.
|
||||
|
||||
Reference in New Issue
Block a user