Skip to content

Commit ef16d56

Browse files
authored
fix(qoder): complete Forward resource lifecycle (#95)
* feat(qoder): manage default memory store lifecycle Change-Id: I35c34cc96127ecbd49678d04f8a554bbd709ffd7 * fix(qoder): route forward-owned resources correctly Change-Id: If1d4eaa1c1713dd5510bec6ab84a0a8407ef63f1 * fix(qoder): keep forward resource ownership consistent Change-Id: I611e80304831f8240784d56362f64bcf45a0e641 * fix(qoder): complete forward resource lifecycle Change-Id: Ifd23baa190a6b1f85c41357aedd6d3f644976da6
1 parent b4ed07e commit ef16d56

26 files changed

Lines changed: 2176 additions & 178 deletions

docs/guides/configure-an-agent.zh-CN.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,77 @@ agents:
362362

363363
`memory_stores` 字段接收存储名称数组。
364364

365+
#### 配置 Qoder Forward 默认 Memory Store
366+
367+
Qoder Forward 本地声明的 Environment 只通过 Forward Environment API 创建。外部 `environment_id`
368+
既可以引用 Managed API 的 Environment,也可以引用 Forward API 的 Environment;OpenCMA 不会创建或
369+
修改此类外部引用,并会在检查其是否存在时自动解析 API 域。Forward Agent 引用的自定义 Skill、Vault/Credential、File 和显式 Memory
370+
Store 均通过 Forward API 管理;本地管理的 Environment、Skill、Vault、File 或 Memory Store 不能同时
371+
供 Managed 与 Forward Agent 共用,应分别声明。Forward File 通过 Agent 的 `files` 字段绑定到 Template。
372+
显式 Memory Store 依赖 `defaults.identity`,并以只读方式挂载到对应的 Identity + Template。
373+
374+
Qoder Forward 会在某个 `(Identity, Template)` 首次创建 Session 时自动建立唯一可写的系统默认
375+
Memory Store。`default_memory_store` 管理的是这个由 Qoder 创建的 Store,而不是在顶层
376+
`memory_stores` 中额外创建一个普通 Store。可以为它设置有业务含义的展示名称、描述以及销毁策略:
377+
378+
```yaml
379+
defaults:
380+
provider: qoder
381+
identity: support-user
382+
383+
identities:
384+
support-user:
385+
external_id: support-user # 由 OpenCMA 管理;不是 identity_id
386+
387+
agents:
388+
support-agent:
389+
# ...其他配置
390+
delivery:
391+
qoder:
392+
type: forward
393+
default_memory_store:
394+
name: "客服群长期记忆"
395+
description: "群聊中确认过的业务知识和处理规则"
396+
delete_on_destroy: false
397+
```
398+
399+
##### apply 行为
400+
401+
- 仅支持 Qoder Forward,并且必须配置 `defaults.identity`。
402+
- `agents apply` 会通过 `defaults.identity` 和当前 Template 定位 `system_managed=true`、
403+
`access=read_write` 的 Store,然后幂等更新 `name` 和可选的 `description`。
404+
- `name` 会成为云端 Store 的展示名称,因此可以将系统生成的默认名称改为有业务含义的名字。
405+
- OpenCMA 不会为了生成 Store 而创建额外的初始化 Session。首次真实 Session 尚未创建时,apply 会提示
406+
pending;Session 创建后再次执行 apply 即可完成名称和描述的收敛。
407+
408+
##### destroy 行为
409+
410+
`delete_on_destroy` 控制执行 `agents destroy` 时是否永久删除这个系统默认 Store:
411+
412+
| 配置 | destroy 结果 |
413+
|---|---|
414+
| 未配置或 `false` | 保留 Store、全部 Memory 和版本历史。这是默认行为。 |
415+
| `true` | 系统挂载解除后,永久删除 Store、全部 Memory 和版本历史。 |
416+
417+
永久删除的执行顺序如下:
418+
419+
1. 删除任何项目资源前,先用 Identity ID 和 Template ID 查询并保存默认 Store ID。
420+
2. archive Template,并删除由 OpenCMA 管理的 Identity,以解除系统挂载。
421+
3. 使用之前保存的 Store ID 永久删除默认 Store。Qoder 可能异步解除系统挂载,因此遇到
422+
`still mounted` 时会进行有界退避重试。
423+
4. 如果挂载冲突仍未解除,尝试 `archive → delete`。真机 Qoder 已验证该路径可以完成永久删除。
424+
425+
如果预检阶段无法解析 Identity、Template,或无法完成 Store 查询,destroy 会在删除任何项目资源之前
426+
整体中止。如果最后的 Store 删除仍失败,命令会返回 `partial`,并把待清理 Store ID 保存在 state 中,
427+
不会把结果显示成完整成功。即使其他资源已经全部删除,之后再次执行 `agents destroy` 也会继续清理。
428+
Store 已不存在时按 `already absent` 处理。
429+
430+
永久删除要求 `defaults.identity` 指向由 OpenCMA 管理的 Identity。外部 `identity_id` 永远不会被
431+
OpenCMA 删除,会继续挂载默认 Store,因此 `delete_on_destroy: true` 会直接校验失败。
432+
433+
> **警告:** `true` 会不可恢复地删除 Store 内容和全部版本历史。除非明确需要清除数据,否则应保持默认
434+
> `false`。`--cascade` 不会覆盖 `delete_on_destroy`。
435+
365436
---
366437

367438
## 多 Agent 协作

docs/reference/configuration.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,13 +280,62 @@ agents:
280280
skills: [ <string> | { type, skill_id, version? } ]
281281
vault: <string>
282282
memory_stores: [ <string> ]
283+
default_memory_store: # Qoder Forward only; requires defaults.identity
284+
name: <string> # 1-255 characters
285+
description: <string> # optional; up to 1024 characters
286+
delete_on_destroy: <boolean> # optional; defaults to false (retain)
283287
environment_variables: { <key>: <string> } # Qoder only
284288
managed_tool_config: { enabled_tools: [ <string> ] } # Qoder Forward delivery only
285289
resources: [ SessionResource ]
286290
multiagent: { type: "coordinator", agents: [...] }
287291
metadata: { <key>: <string> }
288292
```
289293

294+
### Qoder Forward default Memory Store
295+
296+
Qoder creates one writable, system-managed Memory Store for an `(Identity, Template)` pair when its first Forward Session is created. `default_memory_store` lets OpenCMA manage the display metadata and destroy policy of that provider-created Store; it does not declare a second, ordinary entry under the top-level `memory_stores` collection.
297+
298+
```yaml
299+
defaults:
300+
provider: qoder
301+
identity: support-user
302+
303+
identities:
304+
support-user:
305+
external_id: support-user # managed by OpenCMA
306+
307+
agents:
308+
support:
309+
# ...
310+
delivery:
311+
qoder:
312+
type: forward
313+
default_memory_store:
314+
name: "Support group memory"
315+
description: "Confirmed support knowledge and operating rules"
316+
delete_on_destroy: false
317+
```
318+
319+
Apply behavior:
320+
321+
- Requires Qoder Forward delivery and `defaults.identity`.
322+
- Locates the Store mounted as `system_managed: true` and `access: read_write`, then idempotently updates its `name` and optional `description`.
323+
- Does not create an initialization Session. Before the first real Session has created the Store, apply reports the reconciliation as pending. Run apply again after a Session exists.
324+
- `name` changes the provider Store's display name, so it can be meaningful instead of remaining the provider-generated default.
325+
326+
Destroy behavior:
327+
328+
| `delete_on_destroy` | Result |
329+
|---|---|
330+
| omitted or `false` | Retain the Store, its Memories, and all version history. This is the default. |
331+
| `true` | Permanently delete the Store, its Memories, and all version history after its system mount has been removed. |
332+
333+
For permanent deletion, OpenCMA captures and persists the Store ID before archiving the Template and deleting the Identity. Qoder may remove the system mount asynchronously, so OpenCMA uses bounded retries for a `still mounted` conflict. If the conflict remains, it tries `archive → delete`, matching the lifecycle verified against the live Qoder service. A cleanup that still cannot finish is retained in state and reported as a partial destroy; a later `agents destroy` resumes it even when all ordinary resources are already gone.
334+
335+
If the preflight cannot resolve the Identity, Template, or Store lookup, destroy aborts before deleting any project resource. Authentication, permission, and non-retryable validation errors fail immediately. `--cascade` does not override this field.
336+
337+
`delete_on_destroy: true` requires an OpenCMA-managed Identity. An external `identity_id` is never deleted by OpenCMA, so it keeps the system Store mounted and fails configuration validation. Permanent deletion is irreversible; keep the default `false` unless data removal is explicitly required.
338+
290339
| Field | Type | Required | Description |
291340
|-------|------|:--------:|-------------|
292341
| `model` | string \| map<provider,string> | yes | Single model or a per-provider map. |
@@ -301,14 +350,26 @@ agents:
301350
| `mcp_servers[]` | `{ name, type?, url? }` | no | URL (`url`/`http`) or `official` MCP server. |
302351
| `skills[]` | string \| AgentSkillRef | no | Skill name or `{ type: "official"\|"custom", skill_id, version? }`. |
303352
| `vault` | string | no | Vault name. |
353+
| `files` | string[] | no | File declarations inherited by a Qoder Forward Template. These files are created through the Forward File API. |
304354
| `memory_stores` | string[] | no | Bound memory stores. |
355+
| `default_memory_store.name` | string | yes (with `default_memory_store`) | Display name for Qoder Forward's writable system-managed Store; 1–255 characters. |
356+
| `default_memory_store.description` | string | no | Display description for the system-managed Store; up to 1024 characters. |
357+
| `default_memory_store.delete_on_destroy` | boolean | no | Permanently delete the Store during destroy. Defaults to `false` (retain). |
305358
| `environment_variables` | map<string,string> | no | Qoder runtime variables. Managed Sessions use Qoder's `KEY=VALUE;...` wire format; Forward Templates store the map as defaults and Forward Sessions send it under `config.environment_variables`. |
306359
| `managed_tool_config.enabled_tools` | string[] | no | Provider-operated tools the Agent Harness exposes, e.g. `create_forward_schedule`, `list_forward_schedules`, `delete_forward_schedule`. Qoder Forward delivery only; declaring it on managed delivery is a validation error. |
307360
| `resources` | SessionResource[] | no | Resources attached to every managed Session created for the Agent. |
308361
| `multiagent.type` | `"coordinator"` | no | Declare a coordinator agent. |
309362
| `multiagent.agents` | string[] | yes (with multiagent) | Agents it orchestrates. |
310363
| `metadata` | map<string,string> | no | Free-form metadata. |
311364

365+
For Qoder Forward delivery, a locally declared Environment is created only through the Forward Environment API. An
366+
external `environment_id` may reference an Environment from either the Managed API or the Forward API; OpenCMA does
367+
not create or mutate such a reference and resolves its API domain when checking existence. Referenced custom Skills, Vaults and Credentials, Files, and explicit
368+
Memory Stores are created through their Forward APIs. A locally managed Environment, Skill, Vault, File, or Memory
369+
Store cannot be shared by Managed and Forward Agents under one logical declaration; declare separate resources for
370+
the two API domains. Explicit Forward Memory Stores require `defaults.identity` and are mounted read-only to that
371+
Identity and Template.
372+
312373
### Session resources
313374

314375
Qoder and Claude managed Sessions support a provider-neutral GitHub repository resource:

packages/cli/src/commands/apply.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,20 @@ export async function applyCommand(options: {
107107

108108
const actionable = plan.actions.filter((a) => a.action !== "no-op");
109109
if (actionable.length === 0) {
110-
log.success("No changes. Infrastructure is up-to-date.");
110+
if (options.refreshOnly) {
111+
log.success("No changes. Infrastructure is up-to-date.");
112+
return;
113+
}
114+
const s = p.spinner({ output: process.stderr });
115+
s.start("Reconciling provider-managed defaults...");
116+
const result = await executePlannedProject(planned, {
117+
onFeedback: renderRuntimeFeedback,
118+
policy: "force",
119+
concurrency: options.concurrency,
120+
});
121+
s.stop("Provider-managed defaults reconciled.");
122+
if (result.partial) throw new UserError("Apply failed.");
123+
log.success("No resource changes. Infrastructure is up-to-date.");
111124
return;
112125
}
113126

packages/cli/src/commands/destroy.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as p from "@clack/prompts";
22
import {
3+
type DestroyDefaultMemoryStoreResult,
34
type DestroyResourceResult,
45
destroyPlannedProjectResources,
56
planDestroyProjectContext,
@@ -14,7 +15,8 @@ export async function destroyCommand(options: { file: string; yes?: boolean; cas
1415
const planned = planDestroyProjectContext(ctx);
1516
const resources = planned.resources;
1617

17-
if (resources.length === 0) {
18+
const pendingDefaultMemoryStores = planned.defaultMemoryStores.filter((store) => store.memoryStoreId);
19+
if (resources.length === 0 && pendingDefaultMemoryStores.length === 0) {
1820
log.info("No resources in state. Nothing to destroy.");
1921
return;
2022
}
@@ -23,6 +25,13 @@ export async function destroyCommand(options: { file: string; yes?: boolean; cas
2325
for (const r of resources) {
2426
console.log(chalk.red(` - ${formatResourceLabel(r.address)} [${r.remote_id}]`));
2527
}
28+
for (const store of planned.defaultMemoryStores) {
29+
const policy = store.deleteOnDestroy ? chalk.red.bold("permanently delete") : chalk.green("retain");
30+
console.log(` - default_memory_store.${store.agentName} [${policy}]`);
31+
}
32+
if (planned.defaultMemoryStores.some((store) => store.deleteOnDestroy)) {
33+
console.log(chalk.red.bold("\nDefault Memory Store content and all version history will be permanently deleted."));
34+
}
2635

2736
if (!options.yes) {
2837
const shouldDestroy = await p.confirm({
@@ -69,14 +78,22 @@ export async function destroyCommand(options: { file: string; yes?: boolean; cas
6978
activeSpinner = undefined;
7079
},
7180
});
81+
for (const store of result.defaultMemoryStoreResults) renderDefaultMemoryStoreResult(store);
7282

73-
const summary =
74-
result.destroyed === result.resources.length
75-
? chalk.green(`Destroy complete. ${result.destroyed}/${result.resources.length} resources removed.`)
76-
: chalk.yellow(`Destroy complete. ${result.destroyed}/${result.resources.length} resources removed.`);
83+
const summary = !result.partial
84+
? chalk.green(`Destroy complete. ${result.destroyed}/${result.resources.length} resources removed.`)
85+
: chalk.yellow(`Destroy partial. ${result.destroyed}/${result.resources.length} resources removed.`);
7786
p.outro(summary, { output: process.stderr });
7887
}
7988

89+
function renderDefaultMemoryStoreResult(result: DestroyDefaultMemoryStoreResult): void {
90+
const label = `default_memory_store.${result.agentName}`;
91+
if (result.status === "retained") log.success(`${label} — retained`);
92+
else if (result.status === "deleted") log.success(`${label} — permanently deleted`);
93+
else if (result.status === "already_gone") log.warn(`${label} — already absent`);
94+
else log.error(`${label} — delete failed: ${result.error ?? "unknown error"}`);
95+
}
96+
8097
function stopResourceSpinner(spinner: ReturnType<typeof p.spinner> | undefined, result: DestroyResourceResult): void {
8198
const label = formatResourceLabel(result.resource.address);
8299
if (!spinner) {

packages/sdk/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ export {
8181

8282
export type { DeploymentListFilter, DeploymentListResult } from "./internal/providers/interface.ts";
8383

84-
export type { DestroyResourceResult } from "./internal/core/destroy-runtime.ts";
84+
export type {
85+
DestroyDefaultMemoryStoreResult,
86+
DestroyResourceResult,
87+
} from "./internal/core/destroy-runtime.ts";
8588
export {
8689
destroyPlannedProjectResources,
8790
planDestroyProjectContext,

0 commit comments

Comments
 (0)