diff --git a/.specgit.yaml b/.specgit.yaml index e33e7aaef..300153e43 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 517450225..a8dd70ee6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/packages/opencode/src/dag/CONTEXT.md b/packages/opencode/src/dag/CONTEXT.md index 7f2824dd3..34f5871da 100644 --- a/packages/opencode/src/dag/CONTEXT.md +++ b/packages/opencode/src/dag/CONTEXT.md @@ -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. diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index aa2e1bfb5..fc010a014 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -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() - 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, @@ -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 { + const seen = new Map() + 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() for (const block of blocks) { diff --git a/packages/opencode/src/tool/memory-search.ts b/packages/opencode/src/tool/memory-search.ts index c2af0e33f..4574f0cf2 100644 --- a/packages/opencode/src/tool/memory-search.ts +++ b/packages/opencode/src/tool/memory-search.ts @@ -33,11 +33,7 @@ export const MemorySearchTool = Tool.define( 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()