From 8e7bdc63884f286d2fdf5b2075e4e9e82557fc02 Mon Sep 17 00:00:00 2001 From: gongzhiyong Date: Thu, 28 May 2026 21:24:02 +0800 Subject: [PATCH] fix: sync sub runtime callback state --- .../AgentManager普通sub联调整改要求.md | 91 +++++--------- ...通sub敏捷模式-AgentManager对接任务清单.md | 29 ++++- heicode/VERSION | 2 +- heicode/controller/agnet_callback.go | 119 ++++++++++++++++++ .../controller/agnet_control_plane_test.go | 86 +++++++++++++ 5 files changed, 265 insertions(+), 62 deletions(-) diff --git a/docs/integration/AgentManager普通sub联调整改要求.md b/docs/integration/AgentManager普通sub联调整改要求.md index 2823a4b9..cce9c2b9 100644 --- a/docs/integration/AgentManager普通sub联调整改要求.md +++ b/docs/integration/AgentManager普通sub联调整改要求.md @@ -8,34 +8,31 @@ Agent Manager 新版文档 `HEICODE_API_INTEGRATION(5).md` 已经补充了 Runtime 主动 callback、HMAC、artifact、timeline、SK snapshot 等内容,方向基本对齐 Heicode Manager。 -但按 2026-05-28 实测结果,目前还不能认为普通 sub 敏捷模式已经完整闭环。主要问题是: +按 2026-05-28 生产实测,Manager 与 Agent Manager 的普通 sub 核心通讯链路已经跑通,但还不能认为“真实开发执行结果”完整闭环。当前结论是: 1. Agent Manager 当前联调统一使用 IP `http://20.212.121.126`;域名 `https://agent-manager.taijiagnet.com` 后续解析和证书就绪后再切换,不作为当前联调阻塞项。 -2. `POST /api/swarms` 会在极简 body 或 `dry_run:true` 情况下创建真实 swarm,缺少参数校验和 dry-run 保护。 -3. `/api/swarms` 返回的 `swarm_id` 当前无法通过 `/api/swarms/{swarm_id}` 查询,也无法通过 `/api/swarms/{swarm_id}/stop` 停止。 -4. `/api/agnet/deployments/{swarm_id}/stop` 也无法停止 `/api/swarms` 创建出的 swarm,返回 `DEPLOYMENT_NOT_FOUND`。 +2. Manager 生产已切到 `/api/swarms` 创建入口,并验证能拿到 `runtime_swarm_id=swm_*`。 +3. Agent Manager 自动 callback 已能写入 Manager `events/timeline`,Manager `1.4.19` 开始会把 callback 反写到 deployment 快照。 +4. 当前仍缺真实产物和真实用量:Agent Manager status 接口返回 `tokens_used=0`、`artifacts=[]`,并出现整体 `completed` 但 agent 仍 `running` 的状态不一致。 5. 文档写了 callback 重试和死信,但同时又写“发送失败只 warning”,需要明确当前真实实现。 -6. 文档还缺少 Agent 运行 usage / cost 回传字段,后续无法做 NewAPI / Manager 账单归属和审计。 Heicode Manager 侧已确认: - `POST https://code.xinghanlab.com/api/agnet/callbacks/swarm-events` 生产路由在线。 -- callback 使用 `X-Agnet-Service-Token` 认证可以进入业务校验。 -- 不带认证会返回 `CALLBACK_UNAUTHORIZED`。 -- 带认证但缺少事件字段会返回 `CALLBACK_INVALID`。 -- `GET https://code.xinghanlab.com/api/agnet/callbacks/swarm-events/schema` 当前生产返回 404,Manager 会单独复核上线/路由;Agent Manager 不应依赖这个接口才能推送 callback。 +- callback 支持 HMAC 和旧 token 兼容;当前生产为了先跑通自动 callback,已配置 HMAC fallback。 +- `GET https://code.xinghanlab.com/api/agnet/callbacks/swarm-events/schema` 已由 Manager 提供,用于联调前核对事件类型和必填字段。 +- Manager 可接收 `deployment.status_changed`、`phase.changed`、`timeline.updated`、`agent.started/completed/crashed`,并在 `1.4.19` 反写 deployment 详情状态。 ## 2. Agent Manager 必须整改的 P0 | 优先级 | 事项 | 当前问题 | 要求 | |---|---|---|---| | P0 | 固定联调地址 | 当前阶段约定先走 IP,后续再切域名 | 文档和配置先统一使用 `http://20.212.121.126`;域名切换另行确认 | -| P0 | `/api/swarms` 参数校验 | 极简 body / `dry_run:true` 也创建真实 swarm | 无有效 `orchestration_plan`、`callback.url`、`sub_mode` 时返回 4xx;支持 `dry_run` 则不能创建真实资源,不支持则返回 422 | -| P0 | 返回可操作 ID | `/api/swarms` 只返回 `swarm_id`,但 stop/detail 不认这个 ID | 创建响应必须返回 `deployment_id` 和 `swarm_id`,且至少一个 ID 可用于查询和停止 | -| P0 | Swarm detail | `/api/swarms/{swarm_id}` 当前 404 | 实现详情接口,返回当前状态、agents、phase、created_at、updated_at | -| P0 | Swarm stop | `/api/swarms/{swarm_id}/stop` 当前 404 | 实现停止接口,保证 Manager 停止任务时 Runtime 真实停止 | -| P0 | callback 主动推送 | 文档说已支持,但还没完成 Manager 端真实闭环验证 | 创建 Runtime run 后主动向 Manager 推送 phase/timeline/artifact/tool/status callback | -| P0 | usage 回传 | 缺少真实 usage / cost 字段 | 通过 callback 或事件接口回传 token、成本、运行时长、资源使用 | +| P0 | 创建普通 sub deployment | `/api/swarms` 已能创建并返回 `swm_*` | 保持幂等和字段稳定,后续域名切换不能破坏 | +| P0 | deployment detail | Manager 已能记录 callback;Agent Manager status 仍有 `completed` 与 agent `running` 不一致 | 返回真实 phase、agent 状态、失败原因和更新时间 | +| P0 | deployment stop | `/api/agnet/deployments/{deployment_id}/stop` 已可用 | 停止后 Runtime 真实停止,并回调 stopped 事件 | +| P0 | callback 主动推送 | 已验证 status/phase/timeline/agent/budget callback 能进入 Manager | 继续补 artifact、SK、approval 的真实运行数据 | +| P0 | usage 回传 | 当前 budget callback 中 token/cost/runtime 多为 0 | 通过 callback 或事件接口回传真实 token、成本、运行时长、资源使用 | ## 3. 推荐接口契约 @@ -53,10 +50,10 @@ GET /api/agnet/health ### 3.2 创建普通 sub 运行 -Heicode Manager 可以配置 Runtime create path 为 `/api/swarms` 或 `/api/agnet/deployments`。如果 Agent Manager 推荐 `/api/swarms`,则必须支持下面契约。 +普通 sub 当前生产联调使用 Agent Manager `/api/agnet/deployments`,蜂群 `/api/swarms` 另见 `AgentManager蜂群Runtime接口实现要求.md`。 ```http -POST /api/swarms +POST /api/agnet/deployments Authorization: Bearer Content-Type: application/json X-User-ID: @@ -142,11 +139,10 @@ X-Idempotency-Key: ```json { "deployment_id": "dep_runtime_123", - "swarm_id": "swm_runtime_123", "status": "initializing", - "agents": [ + "agent_instances": [ { - "agent_id": "agi_backend_001", + "agent_instance_id": "agi_backend_001", "role": "backend", "status": "pending" } @@ -159,16 +155,13 @@ X-Idempotency-Key: 要求: 1. `deployment_id` 必须可用于 `/api/agnet/deployments/{deployment_id}`、`/api/agnet/deployments/{deployment_id}/stop`。 -2. `swarm_id` 必须可用于 `/api/swarms/{swarm_id}`、`/api/swarms/{swarm_id}/stop`。 -3. 如果只保留一种 ID,必须在文档中明确 Manager 应该配置哪个 stop path。 -4. 同一个 `X-Idempotency-Key` 重复请求必须返回同一个运行,不应重复创建。 -5. 请求缺少 `orchestration_plan`、`callback.url`、`sub_mode`、`user_context` 时必须返回 4xx。 -6. 不支持 `dry_run` 时,包含 `dry_run:true` 的请求必须返回 422,不能创建真实运行。 +2. 同一个 `X-Idempotency-Key` 重复请求必须返回同一个运行,不应重复创建。 +3. 请求缺少 `orchestration_plan`、`callback.url`、`sub_mode`、`user_context` 时必须返回 4xx。 -### 3.3 查询 Swarm +### 3.3 查询普通 sub deployment ```http -GET /api/swarms/{swarm_id} +GET /api/agnet/deployments/{deployment_id} Authorization: Bearer ``` @@ -176,13 +169,12 @@ Authorization: Bearer ```json { - "swarm_id": "swm_runtime_123", "deployment_id": "dep_runtime_123", "status": "running", "phase": "development", - "agents": [ + "agent_instances": [ { - "agent_id": "agi_backend_001", + "agent_instance_id": "agi_backend_001", "role": "backend", "status": "running", "current_task": "实现接口" @@ -193,10 +185,10 @@ Authorization: Bearer } ``` -### 3.4 停止 Swarm +### 3.4 停止普通 sub deployment ```http -POST /api/swarms/{swarm_id}/stop +POST /api/agnet/deployments/{deployment_id}/stop Authorization: Bearer Content-Type: application/json ``` @@ -214,7 +206,6 @@ Content-Type: application/json ```json { - "swarm_id": "swm_runtime_123", "deployment_id": "dep_runtime_123", "status": "stopped", "stopped_at": "2026-05-28T07:40:00Z" @@ -227,16 +218,6 @@ Content-Type: application/json - 停止后可以继续查询状态。 - 停止成功后建议 callback 一条 `deployment.status_changed`,状态为 `stopped`。 -### 3.5 停止 Deployment - -如果 Agent Manager 选择 `/api/agnet/deployments/{deployment_id}/stop` 作为主停止接口,也必须保证 `/api/swarms` 创建响应里的 `deployment_id` 能用于这个接口。 - -```http -POST /api/agnet/deployments/{deployment_id}/stop -``` - -当前实测问题:使用 `/api/swarms` 返回的 `swarm_id` 调该接口返回 `DEPLOYMENT_NOT_FOUND`。需要修复 ID 映射或在响应中返回真实 `deployment_id`。 - ## 4. Callback 要求 Manager 生产 callback 地址: @@ -296,7 +277,6 @@ X-Agnet-Service-Token: "event_id": "evt_phase_001", "event_type": "phase.changed", "deployment_id": "dep_manager_123", - "swarm_id": "swm_runtime_123", "agent_instance_id": "agi_backend_001", "occurred_at": "2026-05-28T07:35:00Z", "correlation_id": "corr_123", @@ -317,7 +297,6 @@ artifact 示例: "event_id": "evt_artifact_001", "event_type": "artifact.created", "deployment_id": "dep_manager_123", - "swarm_id": "swm_runtime_123", "agent_instance_id": "agi_backend_001", "occurred_at": "2026-05-28T07:38:00Z", "correlation_id": "corr_123", @@ -371,7 +350,6 @@ Agent Manager 文档当前缺少真实 usage 字段。普通 sub 后续要支持 "event_id": "evt_usage_001", "event_type": "budget.alert", "deployment_id": "dep_manager_123", - "swarm_id": "swm_runtime_123", "agent_instance_id": "agi_backend_001", "occurred_at": "2026-05-28T07:39:00Z", "correlation_id": "corr_123", @@ -419,7 +397,7 @@ Manager 端已有 approval、credential lease、approve/reject 和 Runtime decis 推荐接口: ```http -POST /api/swarms/{swarm_id}/approvals/{approval_id} +POST /api/agnet/deployments/{deployment_id}/approvals/{approval_id} Authorization: Bearer Content-Type: application/json ``` @@ -432,7 +410,6 @@ Content-Type: application/json "decision": "approved", "manager_deployment_id": "dep_manager_123", "runtime_deployment_id": "dep_runtime_123", - "swarm_id": "swm_runtime_123", "operation": "git.write", "resource_type": "git", "resource_id": "repo_main", @@ -478,9 +455,9 @@ Agent Manager 改完后,按下面步骤验收。 | 步骤 | 操作 | 通过标准 | |---:|---|---| | 1 | `GET /api/agnet/health` | 返回 healthy | -| 2 | `POST /api/swarms` 创建普通 sub run | 返回 `deployment_id`、`swarm_id`、`status` | +| 2 | `POST /api/agnet/deployments` 创建普通 sub run | 返回 `deployment_id`、`status` | | 3 | 重复同一个 `X-Idempotency-Key` 创建 | 不重复创建,返回同一个 ID | -| 4 | `GET /api/swarms/{swarm_id}` | 返回真实状态和 agents | +| 4 | `GET /api/agnet/deployments/{deployment_id}` | 返回真实状态和 agents | | 5 | Runtime 主动 callback `phase.changed` | Manager callback 返回 success | | 6 | Runtime 主动 callback `timeline.updated` | Manager timeline 可查到 | | 7 | Runtime 主动 callback `artifact.created` | Manager artifacts 可查到 | @@ -488,7 +465,7 @@ Agent Manager 改完后,按下面步骤验收。 | 9 | Runtime 主动 callback usage | Manager 能看到 usage 摘要或 callback event | | 10 | Runtime 主动 callback `approval.requested` | Manager pending approval 生成 | | 11 | Manager approve/reject | Agent Manager 收到 decision | -| 12 | `POST /api/swarms/{swarm_id}/stop` | Runtime 停止,状态变为 stopped | +| 12 | `POST /api/agnet/deployments/{deployment_id}/stop` | Runtime 停止,状态变为 stopped | | 13 | 检查日志和 artifact metadata | 不包含明文密钥 | ## 9. 当前实测记录 @@ -502,10 +479,7 @@ Agent Manager 改完后,按下面步骤验收。 | `POST https://code.xinghanlab.com/api/agnet/callbacks/swarm-events` 无认证 | 业务返回 `CALLBACK_UNAUTHORIZED` | Manager callback 路由在线 | | `POST https://code.xinghanlab.com/api/agnet/callbacks/swarm-events` 带旧 token 但空 body | 业务返回 `CALLBACK_INVALID` | 认证通过,进入事件校验 | | `GET https://code.xinghanlab.com/api/agnet/callbacks/swarm-events/schema` | 404 | Manager schema GET 生产未通,Manager 侧需复核 | -| `POST http://20.212.121.126/api/swarms` 带 `dry_run:true` | 200,返回 `swm_080c59b10bbe` | 对方实际创建了 swarm,需要修复参数校验/dry-run | -| `GET http://20.212.121.126/api/swarms/swm_080c59b10bbe` | 404 | 缺少 swarm detail | -| `POST http://20.212.121.126/api/swarms/swm_080c59b10bbe/stop` | 404 | 缺少 swarm stop | -| `POST http://20.212.121.126/api/agnet/deployments/swm_080c59b10bbe/stop` | 404 / `DEPLOYMENT_NOT_FOUND` | `/api/swarms` 返回 ID 与 deployment stop 不兼容 | +| `POST http://20.212.121.126/api/agnet/deployments` | 200 | 普通 sub Runtime deployment 创建可用 | ### 9.1 2026-05-28 追加联调记录 @@ -554,7 +528,7 @@ Heicode Manager callback 接收端: ```text event_id=evt_agent_manager_ip_smoke_1779954828884 event_type=timeline.updated -swarm_id=dep_cd170573cf47 +runtime_deployment_id=dep_cd170573cf47 source=agent-manager-ip-smoke ``` @@ -563,8 +537,7 @@ source=agent-manager-ip-smoke 1. Heicode Manager callback 接收端可用,旧 token 认证和幂等可用。 2. Agent Manager IP 的 `/api/agnet/deployments` 创建、查询、停止可用。 3. Agent Manager 当前没有在实测中产生普通 sub 所需的 phase/timeline/artifact/SK 主动回调。 -4. `/api/swarms` 仍存在 ID 不可查询/不可停止和 `dry_run` 误创建问题。 -5. 因没有可用生产 Manager 用户 session,本次没有完成“客户端登录态 -> Manager 用户态 deployment -> Runtime”的完整端到端测试。 +4. 因没有可用生产 Manager 用户 session,本次没有完成“客户端登录态 -> Manager 用户态 deployment -> Runtime”的完整端到端测试。 ### 9.2 2026-05-28 测试用户客户端模拟链路 @@ -627,7 +600,7 @@ Agent Manager 侧直查 `dep_3335e54e9bdf`: 只有满足以下条件,才能认为普通 sub 敏捷 Runtime 联调完成: -1. Manager 能创建 Runtime run,并保存 Runtime 返回的 `deployment_id` / `swarm_id`。 +1. Manager 能创建 Runtime run,并保存 Runtime 返回的 `deployment_id`。 2. Runtime 能主动回调 Manager,且 callback 通过认证、幂等、脱敏和 schema 校验。 3. Manager 的 artifacts / timeline / sk-snapshots / events 能看到 Runtime 回传数据。 4. Manager 发起 stop 后 Runtime 真实停止。 diff --git a/docs/integration/普通sub敏捷模式-AgentManager对接任务清单.md b/docs/integration/普通sub敏捷模式-AgentManager对接任务清单.md index 7f157a80..5cbb6f4e 100644 --- a/docs/integration/普通sub敏捷模式-AgentManager对接任务清单.md +++ b/docs/integration/普通sub敏捷模式-AgentManager对接任务清单.md @@ -10,6 +10,8 @@ Agent Manager 需要做的是:接收 Manager 生成的 deployment payload,按 `sub_mode=agile` 和 `agile_context` 执行阶段化开发,持续把阶段状态、日志、事件、artifact、审批请求、用量和最终结果回传给 Manager。 +计费边界:PayPal 只作为 Heicode Manager 未来收款渠道,用于余额充值或购买 Heicode 内部订阅套餐;普通 sub 任务执行中的模型费用仍按 Manager/NewAPI 的钱包余额或订阅额度扣费。Agent Manager 不需要接 PayPal,但必须回传模型用量和运行 usage,方便 Manager/NewAPI 做归属和审计。 + 普通 sub 不等于蜂群模式: | 项 | 普通 sub 敏捷 | 蜂群模式 | @@ -34,7 +36,9 @@ Agent Manager 需要做的是:接收 Manager 生成的 deployment payload, | artifact 查询 | 已完成 | `GET /api/agnet/user/deployments/{deployment_id}/artifacts` | | approval 查询/approve/reject | 已完成 | `/api/agnet/approvals` | | V2 body 加密 | 已完成 | 桌面客户端到 Manager 的 POST 请求复用模型调用加密协议 | -| 生产验证 | 已完成 | Manager 1.4.9 已上线,公开 health 和页面点击通过 | +| 生产验证 | 已完成核心链路 | 生产已验证 Manager `1.4.18` 可通过 `/api/swarms` 创建普通 sub run,持久化 `runtime_swarm_id=swm_*`,Agent Manager 自动 callback 可写入 `events/timeline`;`1.4.19` 补齐 callback 后反写 deployment 快照 | +| callback 状态反写 | 已完成 | Manager 接收 `deployment.status_changed`、`phase.changed`、`timeline.updated`、`agent.started/completed/crashed` 后,会同步更新 deployment `status/phase/runtime_state/agent_instances`,避免详情页长期停留 `initializing/pending` | +| PayPal/计费边界文档 | 已完成 | `docs/integration/Heicode-Manager-PayPal支付接入与计费关系说明.md` 已明确收款、余额、订阅、NewAPI 扣费和 Agent 运行预算关系 | ## 3. Agent Manager 需要实现或确认的 P0 @@ -49,6 +53,7 @@ Agent Manager 需要做的是:接收 Manager 生成的 deployment payload, | 支持 stop | 是 | 用户停止任务时 Runtime 必须停止真实执行 | stop 接口返回 stopped | | 支持日志/事件/指标 | 是 | 联调和验收需要排障数据 | 提供 callback 或查询接口 | | 支持用量归属 | 是 | NewAPI / CodeGW 计费要按 deployment/task/role 归属 | 回传 usage/budget 事件或在模型调用中带 correlation | +| 支持 Runtime usage 回传 | 是 | `budget.max_cost_usd` 只是预算上限,真实 Agent 运行费用不能由 Manager 猜 | 回传模型 token/cost、runtime 秒数、CPU/内存等可核对 usage | ## 4. Runtime 创建接口 @@ -416,6 +421,8 @@ Agent Manager 至少需要支持以下一种方式: Agent Manager 调模型时,需要把 Manager 传入的上下文带上,便于 NewAPI / CodeGW 计费归属。 +PayPal 不在这条链路里。PayPal 支付成功后只会把用户权益写入 Heicode 钱包余额或内部订阅;普通 sub 执行时,Agent Manager 仍然按 `billing_context.provider=newapi` 使用 Manager 传入的用户、deployment、role 和模型上下文。 + 至少保留: | 字段 | 来源 | 用途 | @@ -427,6 +434,19 @@ Agent Manager 调模型时,需要把 Manager 传入的上下文带上,便于 | `task_id` | Runtime 内部任务 | 子任务归属 | | `model_id` | billing/default model | 模型用量 | +Agent Manager 回传 usage 时建议至少包含: + +| 字段 | 说明 | +|---|---| +| `model_tokens` | 本 deployment/task/role 消耗的模型 token。 | +| `model_cost_usd` | Runtime 或 NewAPI 可确认的模型成本。 | +| `runtime_seconds` | Agent 实际运行秒数。 | +| `cpu_core_seconds` | 如 Runtime 具备集群指标,应回传 CPU 使用量。 | +| `memory_mb_seconds` | 如 Runtime 具备集群指标,应回传内存使用量。 | +| `billing_source` | 建议为 `newapi`、`manager_wallet`、`manager_subscription` 或 Runtime 约定值。 | + +注意:`budget.max_tokens`、`budget.max_cost_usd`、`budget.max_duration_sec` 是预算和拦截上限,不是已结算金额。Manager 只能展示和审计预算;真实扣费或成本归属必须基于 Agent Manager / Runtime 回传的 usage。 + 如果发生预算告警,回调: ```json @@ -467,7 +487,9 @@ Agent Manager 调模型时,需要把 Manager 传入的上下文带上,便于 | 7 | Manager approve/reject | Runtime 收到 decision 并继续/停止对应动作 | | 8 | Manager stop | Runtime deployment 真实停止 | | 9 | Runtime 回调 completed/failed | Manager detail/timeline 显示终态 | -| 10 | 查日志 | 不出现明文密钥,不丢 `correlation_id` | +| 10 | Runtime 回调用量 | Manager 能看到 model token/cost 或 usage 摘要,且与 deployment/task/role/correlation 关联 | +| 11 | 查日志 | 不出现明文密钥,不丢 `correlation_id` | +| 12 | 生产路由复核 | `GET /api/agnet/callbacks/swarm-events/schema` 返回 200,用户态/后台态接口使用有效登录态通过 smoke | ## 13. Agent Manager 不需要处理的内容 @@ -478,6 +500,7 @@ Agent Manager 调模型时,需要把 Manager 传入的上下文带上,便于 | HeicodeTask 追问/任务卡生成 | 客户端和 Manager 代理处理 | | Azure Key Vault 写入长期密钥 | Manager 负责资源绑定和 `secret_ref` 管理 | | Web 控制台页面展示 | Manager 负责 | +| PayPal 收款和充值订单 | Manager 负责;Agent Manager 只需要回传执行和用量,不直接处理支付 | ## 14. 当前已知缺口 @@ -488,4 +511,6 @@ Agent Manager 调模型时,需要把 Manager 传入的上下文带上,便于 | artifact 真实产出 | Agent Manager | 需要 Runtime 输出 Git/存储/报告 URI | | approval decision 接收路径 | Agent Manager + Manager 配置 | 需确认最终路径是 `/api/swarms/...` 还是 `/api/agnet/deployments/...` | | 用量回传 | Agent Manager + NewAPI/CodeGW | 需要按 user/deployment/task/role 归属 | +| Agent 运行费用真实结算 | Agent Manager + Manager | 当前 Manager 有预算字段和文档口径,缺 Runtime 真实 usage,不能只按预算估算扣费 | +| 生产 callback schema 路由 | Manager 部署/路由 | 本地代码和测试已覆盖,2026-05-28 生产公开访问 `/api/agnet/callbacks/swarm-events/schema` 返回 404,需要重新上线或核对生产镜像/路由 | | 无 body GET 的 V2 签名 | 客户端 + Manager | 这是客户端全链路无 cookie 的后续项,不阻塞 Agent Manager 创建/回调联调 | diff --git a/heicode/VERSION b/heicode/VERSION index f689e8c1..fd4ca57b 100644 --- a/heicode/VERSION +++ b/heicode/VERSION @@ -1 +1 @@ -1.4.18 +1.4.19 diff --git a/heicode/controller/agnet_callback.go b/heicode/controller/agnet_callback.go index b5d71f15..3222abfb 100644 --- a/heicode/controller/agnet_callback.go +++ b/heicode/controller/agnet_callback.go @@ -230,6 +230,124 @@ func callbackMapValue(values map[string]any, key string) map[string]any { return nil } +func agnetCallbackEventRuntimeState(eventType string, payload map[string]any) string { + if state := callbackStringValue(payload, "status"); state != "" { + return state + } + switch eventType { + case "agent.started": + return "running" + case "agent.completed": + return "completed" + case "agent.crashed", "sk_tool.failed", "task.failed": + return "failed" + default: + return "" + } +} + +func upsertAgnetCallbackAgentInstance(record *agnetDeploymentRecord, payload agnetCallbackEnvelope, phase string, runtimeState string) bool { + if record == nil { + return false + } + instanceID := strings.TrimSpace(payload.AgentInstanceID) + source := payload.Payload + role := firstNonEmpty(callbackStringValue(source, "agent_role"), callbackStringValue(source, "role")) + if role == "" && instanceID == "" { + return false + } + failureReason := firstNonEmpty(callbackStringValue(source, "reason"), callbackStringValue(source, "error")) + for idx := range record.AgentInstances { + instance := &record.AgentInstances[idx] + if instanceID != "" && strings.TrimSpace(instance.InstanceID) == instanceID { + if role != "" { + instance.Role = role + } + if phase != "" { + instance.Phase = phase + } + if runtimeState != "" { + instance.RuntimeState = runtimeState + } + if failureReason != "" { + instance.FailureReason = failureReason + } + return true + } + if instanceID != "" && role != "" && strings.TrimSpace(instance.Role) == role && (strings.TrimSpace(instance.RuntimeState) == "queued" || strings.TrimSpace(instance.RuntimeState) == "pending") { + instance.InstanceID = instanceID + instance.Phase = firstNonEmpty(phase, instance.Phase) + instance.RuntimeState = firstNonEmpty(runtimeState, instance.RuntimeState) + instance.FailureReason = failureReason + return true + } + } + record.AgentInstances = append(record.AgentInstances, agnetAgentInstance{ + InstanceID: firstNonEmpty(instanceID, "agi_"+common.GetUUID()[:12]), + Role: role, + Phase: firstNonEmpty(phase, record.Phase), + RuntimeState: firstNonEmpty(runtimeState, record.RuntimeState), + FailureReason: failureReason, + }) + return true +} + +func applyAgnetCallbackDeploymentState(payload agnetCallbackEnvelope, record agnetDeploymentRecord) (agnetDeploymentRecord, bool) { + if strings.TrimSpace(record.DeploymentID) == "" { + return record, false + } + source := payload.Payload + changed := false + switch payload.EventType { + case "deployment.status_changed": + status := callbackStringValue(source, "status") + if status != "" { + record.Status = status + record.RuntimeState = status + changed = true + } + case "phase.changed": + phase := firstNonEmpty(callbackStringValue(source, "stage"), callbackStringValue(source, "phase")) + checkpoint := callbackStringValue(source, "checkpoint") + if phase != "" { + record.Phase = phase + changed = true + } + if checkpoint != "" { + record.RuntimeState = checkpoint + changed = true + } + case "timeline.updated": + phase := firstNonEmpty(callbackStringValue(source, "stage"), callbackStringValue(source, "phase")) + checkpoint := callbackStringValue(source, "checkpoint") + if phase != "" { + record.Phase = phase + changed = true + } + if checkpoint != "" && !containsString([]string{"completed", "failed", "stopped"}, record.RuntimeState) { + record.RuntimeState = checkpoint + changed = true + } + case "agent.started", "agent.completed", "agent.crashed": + phase := firstNonEmpty(callbackStringValue(source, "stage"), callbackStringValue(source, "phase"), record.Phase) + runtimeState := agnetCallbackEventRuntimeState(payload.EventType, source) + if upsertAgnetCallbackAgentInstance(&record, payload, phase, runtimeState) { + changed = true + } + } + if !changed { + return record, false + } + record.UpdatedAt = firstNonEmpty(strings.TrimSpace(payload.OccurredAt), agnetNow()) + agnetMu.Lock() + agnetDeployments[record.DeploymentID] = record + agnetMu.Unlock() + if err := updateAgnetDeploymentRecord(record); err != nil { + common.SysLog("applyAgnetCallbackDeploymentState: " + err.Error()) + } + return record, true +} + func normalizeCallbackArtifact(payload *agnetCallbackEnvelope) { if payload == nil || strings.TrimSpace(payload.Artifact.ArtifactID) != "" { return @@ -601,6 +719,7 @@ func AgnetReceiveSwarmEventCallback(c *gin.Context) { agnetError(c, "CALLBACK_PERSIST_FAILED", "failed to persist approval request") return } + record, _ = applyAgnetCallbackDeploymentState(payload, record) recordAgnetAuditEvent(agnetEvent{ EventID: "evt_" + common.GetUUID()[:12], Event: "callback." + payload.EventType, diff --git a/heicode/controller/agnet_control_plane_test.go b/heicode/controller/agnet_control_plane_test.go index 0d7bcb93..88ed028c 100644 --- a/heicode/controller/agnet_control_plane_test.go +++ b/heicode/controller/agnet_control_plane_test.go @@ -1218,6 +1218,92 @@ func TestAgnetCallbackUsesRuntimeDeploymentIDFallback(t *testing.T) { require.Contains(t, stored.PlanJSON, `"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key"`) } +func TestAgnetCallbackUpdatesDeploymentRuntimeSnapshot(t *testing.T) { + db := setupAgnetControlPlaneTestDB(t) + resetAgnetControlPlaneState(t) + t.Setenv("AGNET_CALLBACK_TOKEN", "callback-token") + + plan := baseAgnetResourceGrantPlan() + plan.UserContext.UserID = "7" + for idx := range plan.Agents[0].ResourceGrants { + plan.Agents[0].ResourceGrants[idx].UserID = "7" + } + createRecorder, createEnvelope := postAgnetCreateUserDeployment(t, 7, plan) + require.True(t, createEnvelope.Success) + var createBody map[string]any + require.NoError(t, common.Unmarshal(createRecorder.Body.Bytes(), &createBody)) + deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string) + runtimeID := "swm-runtime-state-1" + require.NoError(t, db.Model(&model.AgnetDeployment{}). + Where("deployment_id = ?", deploymentID). + Updates(map[string]any{ + "runtime_deployment_id": runtimeID, + "runtime_swarm_id": runtimeID, + }).Error) + resetAgnetControlPlaneState(t) + + postCallback := func(body string) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/callbacks/swarm-events", strings.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + ctx.Request.Header.Set("X-Agnet-Service-Token", "callback-token") + AgnetReceiveSwarmEventCallback(ctx) + require.Equal(t, http.StatusOK, recorder.Code) + require.Contains(t, recorder.Body.String(), `"success":true`) + } + + postCallback(`{ + "event_id":"evt-runtime-agent-started", + "event_type":"agent.started", + "deployment_id":"swm-runtime-state-1", + "swarm_id":"swm-runtime-state-1", + "agent_instance_id":"agi-runtime-builder", + "occurred_at":"2026-05-28T12:40:00Z", + "payload":{"agent_role":"builder","status":"running"} + }`) + postCallback(`{ + "event_id":"evt-runtime-phase-deploy", + "event_type":"phase.changed", + "deployment_id":"swm-runtime-state-1", + "swarm_id":"swm-runtime-state-1", + "occurred_at":"2026-05-28T12:41:00Z", + "payload":{"stage":"deploy","checkpoint":"completed","summary":"Runtime completed"} + }`) + postCallback(`{ + "event_id":"evt-runtime-agent-completed", + "event_type":"agent.completed", + "deployment_id":"swm-runtime-state-1", + "swarm_id":"swm-runtime-state-1", + "agent_instance_id":"agi-runtime-builder", + "occurred_at":"2026-05-28T12:41:30Z", + "payload":{"agent_role":"builder","status":"completed","stage":"deploy"} + }`) + postCallback(`{ + "event_id":"evt-runtime-status-completed", + "event_type":"deployment.status_changed", + "deployment_id":"swm-runtime-state-1", + "swarm_id":"swm-runtime-state-1", + "occurred_at":"2026-05-28T12:42:00Z", + "payload":{"status":"completed"} + }`) + + var stored model.AgnetDeployment + require.NoError(t, db.Where("deployment_id = ?", deploymentID).First(&stored).Error) + require.Equal(t, "completed", stored.Status) + require.Equal(t, "deploy", stored.Phase) + require.Equal(t, "completed", stored.RuntimeState) + require.Equal(t, "2026-05-28T12:42:00Z", stored.UpdatedAtText) + + var instances []agnetAgentInstance + require.NoError(t, common.UnmarshalJsonStr(stored.AgentInstancesJSON, &instances)) + require.Len(t, instances, 1) + require.Equal(t, "agi-runtime-builder", instances[0].InstanceID) + require.Equal(t, "builder", instances[0].Role) + require.Equal(t, "deploy", instances[0].Phase) + require.Equal(t, "completed", instances[0].RuntimeState) +} + func TestAgnetCallbackRejectsPlaintextSecretsAndMissingToken(t *testing.T) { setupAgnetControlPlaneTestDB(t) resetAgnetControlPlaneState(t)