Skip to content
Merged
10 changes: 6 additions & 4 deletions cmd/devsetup/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,11 @@ type devConfig struct {
// devPluginsConfig mirrors the service's config.PluginConfig; the interval is a
// string here because TOML durations are spelled "5m" and the service parses it.
type devPluginsConfig struct {
MarketplaceURL string `toml:"marketplace_url"`
MarketplaceBranch string `toml:"marketplace_branch"`
SyncInterval string `toml:"sync_interval"`
SyncEnabled bool `toml:"sync_enabled"`
PersonalAgentTarget string `toml:"personal_agent_target"`
MarketplaceURL string `toml:"marketplace_url"`
MarketplaceBranch string `toml:"marketplace_branch"`
SyncInterval string `toml:"sync_interval"`
SyncEnabled bool `toml:"sync_enabled"`
}

// defaultPluginsConfig is the backfill for config.toml files predating the
Expand Down Expand Up @@ -252,6 +253,7 @@ func renderEnv(cfg *devConfig) string {
{"GATEWAY_IDAAS_CLIENT_ID", cfg.IDaaS.ClientID},
{"GATEWAY_IDAAS_DISPLAY_NAME_FIELD", cfg.IDaaS.DisplayNameField},
{"CLOUD_PLUGINS_MARKETPLACE_URL", cfg.Plugins.MarketplaceURL},
{"CLOUD_PLUGINS_PERSONAL_AGENT_TARGET", cfg.Plugins.PersonalAgentTarget},
{"CLOUD_PLUGINS_MARKETPLACE_BRANCH", cfg.Plugins.MarketplaceBranch},
{"CLOUD_PLUGINS_SYNC_INTERVAL", cfg.Plugins.SyncInterval},
{"CLOUD_PLUGINS_SYNC_ENABLED", strconv.FormatBool(cfg.Plugins.SyncEnabled)},
Expand Down
20 changes: 20 additions & 0 deletions cmd/devsetup/personal_agent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package main

import (
"strings"
"testing"
)

// Setup must not discard a rollout target explicitly supplied in the template.
func TestPersonalAgentTargetEnvironment(t *testing.T) {
cfg, err := loadConfig(writeConfig(t, "[database]\ndsn='x'\n[plugins]\npersonal_agent_target='x86_64-unknown-linux-gnu'\nsync_enabled=false\n"))
if err != nil {
t.Fatal(err)
}
if cfg.Plugins.PersonalAgentTarget != "x86_64-unknown-linux-gnu" {
t.Fatal("target was discarded")
}
if !strings.Contains(renderEnv(&cfg), "CLOUD_PLUGINS_PERSONAL_AGENT_TARGET=\"x86_64-unknown-linux-gnu\"\n") {
t.Fatal("target override was not rendered")
}
}
3 changes: 3 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ func run() (runErr error) {
return e
}
configureCollaboration(store, cfg.Collaboration.DevelopmentFixtures, log)
if cfg.Plugins.PersonalAgentTarget != "" {
store.PersonalAgentRuntime = &core.PersonalAgentRuntime{Target: cfg.Plugins.PersonalAgentTarget, ContractVersion: 1, Protocols: []string{"openai-completions", "anthropic-messages"}}
}
// The B side of the Agent Run seam: Cloud's IssueRun/Thread lifecycle is driven by these five
// callbacks, each of which runs inside the control-plane transaction that just persisted the
// evidence it reacts to (controller-integration D6). They are the only writer of the B-side
Expand Down
2 changes: 2 additions & 0 deletions config.toml.template
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ client_secret = ""
display_name_field = "userName"

[plugins]
# Enable only after Node/Controller and both plugin adapters are deployed; empty disables admission.
personal_agent_target = ""
# Plugin marketplace the cloud server keeps a catalog snapshot of. `task setup` turns these
# values into CLOUD_PLUGINS_* overrides in .local/dev.env, which configs/config.yaml already
# accepts. Leave the section unchanged to use the default marketplace.
Expand Down
1 change: 1 addition & 0 deletions docs/agent-runs/startup-settlement.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ atomically** and settles the run in these cases:
|---|---|
| The user ended the session first, or the run was cancelled | `cancelled` |
| The current Node is initialized, connected, with a heartbeat within 30 seconds, and **definitively** lacks the model proxy capability while the run uses a personal model | `failed`, `failureReason = model_proxy_unavailable` |
| Such a Node **definitively** lacks the adapter capability the run's frozen Agent declaration needs (target, contract version or protocol differs) | `failed`, `failureReason = agent_adapter_unavailable` |
| The startup window expired | `failed`, `failureReason = agent_start_failed` |

Withdrawal and dispatch registration share the same transaction lock, so a registration arriving after the
Expand Down
1 change: 1 addition & 0 deletions docs/agent-runs/startup-settlement.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
|---|---|
| 用户先结束了会话,或运行被取消 | `cancelled` |
| 当前 Node 已初始化、已连接、30 秒内有心跳,并且**明确**不具备模型代理能力,而运行使用个人模型 | `failed`,`failureReason = model_proxy_unavailable` |
| 同样的 Node **明确**不具备运行冻结的 Agent 声明所需的 adapter 能力(target、契约版本或协议不符) | `failed`,`failureReason = agent_adapter_unavailable` |
| 启动窗口到期 | `failed`,`failureReason = agent_start_failed` |

撤回与派发登记共用同一把事务锁,所以撤回之后迟到的登记找不到工作项。运行进入 `releasing`,交付状态为 `skipped`,排队中的用户
Expand Down
2 changes: 2 additions & 0 deletions docs/models/model-access.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Agent runs they trigger use that model. The key is kept by the platform, and eve
are proxied through [model-gateway](model-gateway.en.md): the sandbox running the Agent never receives the
original key, only a short-lived token valid for that run alone.

Declared Agent admission is disabled by default. After host rollout, `plugins.personal_agent_target` opts into one exact Linux target. Cloud freezes package/declaration/adapter identities, configuration revision, model metadata and numeric literals. Controller and Node must prove matching target, contract and protocol before execution. Validated Settings JSON travels as text to avoid rounding integers or decimals; runtime authority exists only in mTLS grants and the live plugin startup channel. An incompatible current Node settles as unavailable; disconnected or stale Nodes are not definitive refusals.

This document covers four things on the Cloud side: connections and the default model, key custody, the
binding fixed for a run, and short-lived grants for model requests.

Expand Down
2 changes: 2 additions & 0 deletions docs/models/model-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

本文说明 Cloud 一侧的四件事:连接与默认模型、密钥的保管、运行时固定的绑定,以及模型请求的临时授权。

声明式 Agent 运行默认关闭;部署完成后以 `plugins.personal_agent_target` 指定精确 Linux target。Cloud 冻结包、声明、adapter 版本、配置 revision、模型元数据和数值原文;Controller 与 Node 必须证明同一 target、版本及协议才可开始。Settings 以经过校验的 JSON 文本跨控制边界,避免大整数和高精度小数被中间层舍入;运行 token 只在 mTLS grant 与插件启动内存通道出现。当前 Node 不支持时按能力缺失结算,未连接或过期的 Node 不构成确定性拒绝。

## 连接与默认模型

个人模型连接属于**用户本人**,与租户无关:用户加入任何空间之前就可以配置。
Expand Down
1 change: 1 addition & 0 deletions docs/operations/configuration.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Each process in this repository has its own configuration sources:
| `directory.endpoint`, `hw_id`, `environment`, `app_key_file` | Empty | The corporate employee directory; an empty `endpoint` means a public deployment, see [Users, tenants and members](../identity/tenancy.en.md#joining-a-tenant) |
| `collaboration.development_fixtures` | `false` | Development fixtures; production must keep it off, see [Issue collaboration](../collaboration/interactions.en.md#ports-issues-own-only-the-consuming-side-contract) |
| `plugins.sync_enabled` | — | Whether to sync the plugin marketplace |
| `plugins.personal_agent_target` | Empty | Declared personal-Agent deployment target; empty disables it. Supports `x86_64-unknown-linux-gnu` or `aarch64-unknown-linux-gnu`; enable only after adapter rollout |
| `plugins.marketplace_url`, `marketplace_branch`, `sync_interval` | — | Required when sync is on: an `https` Git URL (no query or fragment), a valid branch name, a positive interval |
| `plugins.asset_import_enabled`, `asset_import_interval` | —, `30s` | Workflow plugin asset import |
| `issue_runs.thread_idle_timeout` | `15m` | The Thread idle window; non-positive values take the default |
Expand Down
1 change: 1 addition & 0 deletions docs/operations/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
| `directory.endpoint`、`hw_id`、`environment`、`app_key_file` | 空 | 企业员工目录;`endpoint` 为空即公网部署,见 [用户、租户与成员](../identity/tenancy.md#加入租户) |
| `collaboration.development_fixtures` | `false` | 开发夹具,生产必须关闭,见 [Issue 协作](../collaboration/interactions.md#端口issue-只拥有消费侧的契约) |
| `plugins.sync_enabled` | — | 是否同步插件市场 |
| `plugins.personal_agent_target` | 空 | 声明式个人 Agent 的部署 target;空值关闭,支持 `x86_64-unknown-linux-gnu` 或 `aarch64-unknown-linux-gnu`,部署完 adapter 再启用 |
| `plugins.marketplace_url`、`marketplace_branch`、`sync_interval` | — | 开启同步时必填:`https` Git 地址(不带查询和片段)、合法分支名、正的间隔 |
| `plugins.asset_import_enabled`、`asset_import_interval` | —、`30s` | 工作流插件资产导入 |
| `issue_runs.thread_idle_timeout` | `15m` | Thread 空闲窗口;非正值取默认 |
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/issues/thread/README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Not owned: creating and listing runs (`useRuns` in `features/issues/api`), SSE c
| `run-delivery.tsx` | The Revision delivery line under the Thread (`role=status`, named "Revision 交付"): a registered Revision shows its short commit and whether the run delivered new commits (or, for a resumed run with no new commits, that it reused the resumed work); otherwise `result.deliveryState` explains a skip or failure, and an ended session whose run has not settled shows saving; Revisions are delivered over Git, so no bundle size is shown |
| `run-resume.tsx` | The resume line above the Thread (`role=note`, named "续接"): when the run resumed an earlier Revision of the same Issue, it finds that Revision among the Issue's runs and shows its short commit; a fresh run renders nothing |
| `run-preparation.tsx` | `RunPreparation` displays server-projected environment, clone, Agent preparation/start phases, the three-attempt clone budget and retry waiting; `snapshot_not_ready` shows as waiting for the Project's first repository sync (a wait, not a failure), and `repository_sync_failed` shows as a failure whatever the stage, with `syncErrorCode` appended only when it is a snake_case code (other values are dropped); `SessionFailure` renders only allowlisted safe codes, with no waiting/idle presentation after failure |
| `thread-failure.ts` | `sessionFailureMessage`: shared finite failure messages for status and durable entries; unknown fields become fixed messages without provider diagnostics |
| `thread-failure.ts` | `sessionFailureMessage`: shared finite failure messages for status and durable entries; each Agent model configuration failure (missing, inconsistent, unsupported protocol, mismatched grant, adapter not applied, Node without the capability) has its own message; unknown fields become fixed messages without provider diagnostics |
| `thread-panel.tsx` | `IssueThreadPanel`: run selection, the "Agent 会话" heading, the state badge, the waiting notice and composition |
| `*.test.ts(x)` | Pure-function unit tests and MSW integration tests |

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/issues/thread/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Issue 详情页主体的「Agent 会话」:默认读取最近一次 Agent Run
| `run-delivery.tsx` | 会话下方的 Revision 交付一行(`role=status`,名称「Revision 交付」):已登记的 Revision 显示短提交与是否交付了新提交(续接后无新提交时说明沿用了续接的成果);Revision 经 Git 交付,不显示 bundle 大小,否则按 `result.deliveryState` 说明跳过或失败,会话已结束但运行未结算时显示保存中 |
| `run-resume.tsx` | 会话上方的续接一行(`role=note`,名称「续接」):运行续接了同一 Issue 之前的 Revision 时,从该 Issue 的运行列表中找到它并显示短提交;全新运行不渲染 |
| `run-preparation.tsx` | `RunPreparation` 显示服务器提供的环境、克隆、Agent 准备和启动阶段,以及三次克隆预算、重试等待;`snapshot_not_ready` 显示为等待项目首次同步仓库(等待而非失败),`repository_sync_failed` 无论阶段都显示为失败,并附上形如 snake_case 的 `syncErrorCode`(其他值丢弃);`SessionFailure` 仅显示白名单安全错误码,失败不继续显示等待或空闲 |
| `thread-failure.ts` | `sessionFailureMessage`:状态提示与持久失败条目共用的有限错误分类文案;未知字段替换为固定提示,不显示供应商诊断 |
| `thread-failure.ts` | `sessionFailureMessage`:状态提示与持久失败条目共用的有限错误分类文案;Agent 模型配置的几类失败(缺少、不一致、协议不支持、授权不符、adapter 未应用、节点不支持)各有文案;未知字段替换为固定提示,不显示供应商诊断 |
| `*.test.ts(x)` | 纯函数单测与 MSW 集成测试 |

## 依赖
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/features/issues/thread/thread-failure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'

import { sessionFailureMessage } from './thread-failure'

describe('sessionFailureMessage', () => {
it('names each Agent model configuration failure instead of a generic one', () => {
expect(sessionFailureMessage('agent_model_protocol_mismatch')).toBe(
'Agent 插件不支持所选模型连接的协议(agent_model_protocol_mismatch)',
)
expect(sessionFailureMessage('agent_adapter_unavailable')).toBe(
'执行节点不支持该 Agent 的模型配置(agent_adapter_unavailable)',
)
})

it('replaces an unknown code or provider text with the generic failure', () => {
expect(sessionFailureMessage('401 from https://provider.example')).toBe(
'Agent 会话失败(agent_failed)',
)
expect(sessionFailureMessage(undefined)).toBe('Agent 会话失败(agent_failed)')
})
})
7 changes: 7 additions & 0 deletions frontend/src/features/issues/thread/thread-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ const FAILURE_LABELS: Record<string, string> = {
model_access_unavailable: '模型授权不可用',
model_access_expired: '模型授权已过期',
thread_unavailable: '会话记录不可用',
agent_adapter_unavailable: '执行节点不支持该 Agent 的模型配置',
agent_model_configuration_required: '该 Agent 缺少模型配置',
agent_model_configuration_invalid: 'Agent 模型配置与已安装的插件不一致',
agent_model_protocol_mismatch: 'Agent 插件不支持所选模型连接的协议',
model_grant_mismatch: '模型授权与运行配置不一致',
agent_model_adapter_failed: 'Agent 无法应用模型配置',
agent_model_adapter_not_applied: 'Agent 未确认已应用模型配置',
clone_attempts_exhausted: '获取仓库失败,自动重试次数已用尽',
repository_sync_failed: '项目仓库同步失败,无法获取仓库',
}
Expand Down
20 changes: 20 additions & 0 deletions integration/node_model_adapter_migration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package integration

import "testing"

// An upgraded legacy schema gains only empty capability evidence; no existing Node is certified.
func TestMigration0060ModelAdapterFreshAndUpgrade(t *testing.T) {
for _, upgrade := range []bool{false, true} {
pool, _ := testSchema(t, "test_model_adapter_")
if upgrade {
applyMigrationsUpTo(t, pool, migrationsBefore(t, "0060_node_model_adapter.sql"))
}
migratedTwice(t, newStoreOnSchema(t, pool))
var dataType, nullable, defaultValue string
must(t, pool.QueryRow(`SELECT data_type,is_nullable,column_default FROM information_schema.columns
WHERE table_schema=current_schema() AND table_name='node_instances' AND column_name='model_adapter'`).Scan(&dataType, &nullable, &defaultValue))
if dataType != "jsonb" || nullable != "NO" || defaultValue != "'{}'::jsonb" {
t.Fatalf("upgrade=%v: wrong legacy capability default %s/%s/%s", upgrade, dataType, nullable, defaultValue)
}
}
}
2 changes: 2 additions & 0 deletions internal/api/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,11 +478,13 @@ func newRouter(store *core.Store, auth *core.Authenticator, log *zap.Logger, leg
// Model capability belongs to the production Controller gRPC registration/record.
if strings.HasPrefix(route.Action, "node_") {
delete(out, "modelProxy")
delete(out, "modelAdapter")
}
// Claim and snapshot both return the same snapshot document.
if nodes, ok := out["nodes"].([]core.Object); ok {
for _, node := range nodes {
delete(node, "modelProxy")
delete(node, "modelAdapter")
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions internal/config/README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

`Load` uses a local Viper instance to load and validate service configuration, returning `Config`. There is no global mutable configuration.

An empty `plugins.personal_agent_target` disables declared personal-Agent admission. An explicit supported Linux target opts in only after Controller/Node and plugin adapters are deployed; execution still checks the current incarnation's target, contract and protocol.

| File | Responsibility |
|---|---|
| `config.go` | Main types, defaults, explicit environment bindings and common validation |
Expand Down
2 changes: 2 additions & 0 deletions internal/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

`Load` 用局部 Viper 实例加载服务配置,校验后返回 `Config`;没有全局可变配置。

`plugins.personal_agent_target` 为空时不启用声明式个人 Agent 运行;可显式指定当前 Node 支持的 Linux target。Controller/Node 与插件 adapter 部署完成后再配置;运行仍校验当前 incarnation 的 target、版本和协议。

| 文件 | 职责 |
|---|---|
| `config.go` | 主结构、默认值、显式环境变量绑定和通用校验 |
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ type WorkflowConfig struct {
// PluginConfig is the cloud-side plugin marketplace configuration. Leaf keys
// are bound to CLOUD_PLUGINS_* environment overrides like every other section.
type PluginConfig struct {
// PersonalAgentTarget opts into deployed v1 adapters for one exact Linux execution target.
// Empty keeps declared-agent admission disabled during host rollout.
PersonalAgentTarget string `mapstructure:"personal_agent_target"`
// MarketplaceURL is the git repository the marketplace catalog is synced from.
MarketplaceURL string `mapstructure:"marketplace_url"`
// MarketplaceBranch is the branch checked out and fast-forwarded on every sync.
Expand Down Expand Up @@ -280,6 +283,9 @@ const DefaultAssetImportInterval = 30 * time.Second
// marketplace address and branch must be present and well-formed so a typo
// fails at startup instead of surfacing as a silently empty catalog.
func validatePlugins(p PluginConfig) error {
if p.PersonalAgentTarget != "" && p.PersonalAgentTarget != "x86_64-unknown-linux-gnu" && p.PersonalAgentTarget != "aarch64-unknown-linux-gnu" {
return fmt.Errorf("plugins.personal_agent_target must name a supported Linux target")
}
if p.AssetImportEnabled && p.AssetImportInterval <= 0 {
return fmt.Errorf("plugins.asset_import_interval must be a positive duration when plugins.asset_import_enabled is true")
}
Expand Down
15 changes: 15 additions & 0 deletions internal/config/personal_agent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package config

import "testing"

// Rollout is opt-in; a misspelled target must never silently enable another runtime.
func TestPersonalAgentTargetDeploymentGate(t *testing.T) {
for _, target := range []string{"", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"} {
if err := validatePlugins(PluginConfig{PersonalAgentTarget: target}); err != nil {
t.Fatal(err)
}
}
if err := validatePlugins(PluginConfig{PersonalAgentTarget: "linux"}); err == nil {
t.Fatal("unknown execution target accepted")
}
}
Loading
Loading