Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
version: 1
delivery: issue348
delivery: issue374
context:
kind: branch
branch: feat/348-issue348
branch: feat/375-issue375
issues:
- 348
pr: 373
- 374
pr: 375
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@
## Git Workflow (铁律)

```
feat/**, fix/** ──PR(Typecheck 门禁)──▶ dev ──push 触发全量测试──▶
feat/**, fix/** ──PR(Typecheck + Unit Tests 门禁)──▶ dev ──push 触发全量测试──▶
dev ──手动 release-fork──▶ prerelease 测试版
dev ──PR(全量测试门禁)──▶ main ──手动 release-fork──▶ 正式版
```

**分层门禁**:`dev` 是快速集成层(Typecheck),`main` 是正式质量门禁(Typecheck + 全量 Unit Tests + E2E)。所有改动通过 PR 流转,禁止直推 `main` 和 `dev`(由 GitHub Rulesets 强制)。
**分层门禁**:`dev` 是快速集成层(Typecheck + Unit Tests (linux);E2E 不阻塞),`main` 是正式质量门禁(Typecheck + 全量 Unit Tests + E2E)。所有改动通过 PR 流转,禁止直推 `main` 和 `dev`(由 GitHub Rulesets 强制)。

| Branch | 直推 | PR 门禁 | CI 触发 | Purpose |
|--------|------|---------|---------|---------|
| `{type}/**` | ✅ 允许 | — | ❌ 不跑 | 开发分支,频繁变更 |
| `dev` | ❌ 禁止 | PR 必须通过 **Typecheck** | ✅ push 触发 Typecheck + 全量测试 | 快速集成层 |
| `dev` | ❌ 禁止 | PR 必须通过 **Typecheck + Unit Tests (linux)** | ✅ push 触发 Typecheck + 全量测试 | 快速集成层 |
| `main` | ❌ 禁止 | PR 必须通过 **Typecheck + Unit Tests + E2E (linux + windows)** | ✅ push 触发全量 | 正式质量门禁 + 发版 |

**流程**:
1. 从 `main` 切出 `feat/**` 或 `fix/**` 分支开发
2. PR → `dev`(Typecheck 门禁,快速合并)
2. PR → `dev`(Typecheck + Unit Tests (linux) 门禁,快速合并)
3. push 到 `dev` 自动触发全量测试验证
4. 从 `dev` 手动 `release-fork` → 产出 **prerelease** 测试版
5. PR `dev` → `main`(全量测试门禁:Typecheck + Unit Tests + E2E)
Expand Down
8 changes: 4 additions & 4 deletions packages/opencode/src/dag/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,16 @@ Workflow Orchestration turns one user objective into one durable DAG. Its model-
- Parsing, file-only compatibility, strict action decoding, Block compilation, and profile diagnostics are not reimplemented by callers.
- `portable` validation does not load user environment catalogs. `environment` validation reads current catalogs and verifies actual model availability.
- No workflow event or durable mutation occurs before a valid Prepared Workflow Graph exists.

## Conventions

- One user objective has at most one live DAG; route expansion stays inside that DAG (issue #348: a modeling convention enforced by orchestrator guidance — workflow-routing and orchestration-policy — not by the engine; `dag.create` and the workflow tool's start accept a session with a live workflow. The runtime tolerates the violation with bounded consequences: the wake model aggregates across workflows and a goal is blocked by any DAG lease. When two live DAGs share one workspace, the plan block's disjoint-write-set discipline does NOT carry across workflows — authors must keep concurrent workflows on disjoint worktrees or serialize them).
- The model-facing schema contains fields the model owns. Session/Project identity, admission audit state, model assignment, and other runtime-derived fields remain hidden.
- Model-facing graph actions expose only `spec_path`; graph fields live in YAML so provider tool-call serialization cannot turn a nested graph into a string.
- Legacy YAML may be adapted at the file boundary without making legacy fields valid inline input.
- Runtime Admission and Workflow Authoring Check have separate names, state, and responsibilities.
- Dependents of a reporting checkpoint must be gated on its output; authoring rejects ungated shapes at start/validate AND at replan/extend fragment actions, and the runtime replan/extend mutation seam re-checks the merged graph (exempting checkpoints already terminal in the durable graph — they are settled and immutable, the spawn-before-verdict race is past; runtime create remains deliberately unchanged). A gated checkpoint must declare `output_schema` (authoring obligation).

## Conventions

- One user objective has at most one live DAG; route expansion stays inside that DAG (issue #348: a modeling convention enforced by orchestrator guidance — workflow-routing and orchestration-policy — not by the engine; `dag.create` and the workflow tool's start accept a session with a live workflow. The runtime tolerates the violation with bounded consequences: the wake model aggregates across workflows and a goal is blocked by any DAG lease. When two live DAGs share one workspace, the plan block's disjoint-write-set discipline does NOT carry across workflows — authors must keep concurrent workflows on disjoint worktrees or serialize them).

## Boundaries

- `WorkflowAuthoring` owns source interpretation and authoring diagnostics.
Expand Down
52 changes: 28 additions & 24 deletions packages/opencode/src/dag/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,30 +262,7 @@ function compileBlock(
contract: AGGREGATOR_CONTRACT,
required: true,
reportToParent: false,
inputMapping: Object.fromEntries(
(() => {
// #349/BLK-02: writer ids may mix hyphens and underscores
// ("foo-bar" vs "foo_bar") whose -→_ normalization collides on
// the same mapping key — Object.fromEntries would silently drop
// one writer's evidence (and its files escape the aggregator's
// overlap detection). Reject the shape at compile time.
const seen = new Map<string, string>()
for (const writerID of aggregation.writerIDs) {
const key = writerID.replace(/-/g, "_")
const prior = seen.get(key)
if (prior !== undefined) {
throw new Error(
`Parallel implementation writers "${prior}" and "${writerID}" normalize to the same input-mapping key "${key}" — their aggregator evidence keys would collide. Rename one of the writers so the ids differ beyond hyphens vs underscores`,
)
}
seen.set(key, writerID)
}
return aggregation.writerIDs.flatMap((writerID: string) => [
[`${writerID.replace(/-/g, "_")}_changed_files`, `${writerID}.output.changed_files`],
[`${writerID.replace(/-/g, "_")}_summary`, `${writerID}.output.summary`],
])
})(),
),
inputMapping: aggregatorEvidenceMapping(aggregation.writerIDs),
outputSchema: IMPLEMENTATION_SCHEMA,
}),
...lanes,
Expand Down Expand Up @@ -449,6 +426,33 @@ interface WriterAggregation {
verificationID: string
}

/**
* The aggregator's per-writer evidence mapping. #349/BLK-02: writer ids may
* mix hyphens and underscores ("foo-bar" vs "foo_bar") whose -→_ normalization
* collides on the same mapping key — Object.fromEntries would silently drop
* one writer's evidence (and its files escape the aggregator's overlap
* detection), so the shape is rejected at compile time.
*/
function aggregatorEvidenceMapping(writerIDs: string[]): Record<string, string> {
const seen = new Map<string, string>()
for (const writerID of writerIDs) {
const key = writerID.replace(/-/g, "_")
const prior = seen.get(key)
if (prior !== undefined) {
throw new Error(
`Parallel implementation writers "${prior}" and "${writerID}" normalize to the same input-mapping key "${key}" — their aggregator evidence keys would collide. Rename one of the writers so the ids differ beyond hyphens vs underscores`,
)
}
seen.set(key, writerID)
}
return Object.fromEntries(
writerIDs.flatMap((writerID: string) => [
[`${writerID.replace(/-/g, "_")}_changed_files`, `${writerID}.output.changed_files`],
[`${writerID.replace(/-/g, "_")}_summary`, `${writerID}.output.summary`],
]),
)
}

function aggregateParallelWriters(blocks: WorkflowBlock[]) {
const aggregations = new Map<string, WriterAggregation>()
for (const block of blocks) {
Expand Down
6 changes: 1 addition & 5 deletions packages/opencode/src/tool/memory-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,7 @@ export const MemorySearchTool = Tool.define<typeof Parameters, Metadata, never>(

const memory = Option.getOrUndefined(yield* Effect.serviceOption(Memory.Service))
const sessions = Option.getOrUndefined(yield* Effect.serviceOption(Session.Service))
// #350: when the service exists but Memory is inert, say why instead
// of a bare "unavailable" — the reason tells the user what to do
// (e.g. run /init, then /memory on).
if (!memory) return unavailable()
if (!sessions) return unavailable()
if (!memory || !sessions) return unavailable()
const current = yield* sessions.get(ctx.sessionID).pipe(Effect.option)
if (Option.isNone(current) || current.value.parentID) return unavailable()

Expand Down
Loading