From 7352385cc63f12317b0b7603c912afa743926965 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:29:45 +0800 Subject: [PATCH 1/3] fix(delegation): reserve ancestor copy admission before publication Problem: Competing continuations could create two copies of one ancestor in the same parent session, leaving later continuation ambiguous. Root cause: The source ref lock ended before a newly allocated copy published metadata, and each preparation acquired a different destination ref lock. Fix: Reserve the canonical nearest-source and target-parent pair before searching for copies. Transfer the reservation to the returned run's existing release lifetime, and release it on failed preparation. Preserve independent parents, sources, current-reference locks and persisted formats. Verification: Real TaskTool publication interleavings and Store regressions; exact old-owner overlay fails 9 named events with 5 controls. Root 240-package coverage, delegation/sessionstore race, vet, pinned lint, make check, actionlint and Windows/Linux compile-only checks pass. Windows named-step inventory validation rejects missing package completion and skipped cases. Refs #14025 --- .github/workflows/ci.yml | 37 +++ docs/SUBAGENT_COPY_ADMISSION.md | 56 +++++ .../delegation/ancestor_copy_admission.go | 58 +++++ .../ancestor_copy_admission_test.go | 235 ++++++++++++++++++ internal/runtime/delegation/subagent_store.go | 11 +- .../task_ancestor_copy_admission_test.go | 95 +++++++ .../14025-ancestor-copy-admission.md | 15 ++ 7 files changed, 500 insertions(+), 7 deletions(-) create mode 100644 docs/SUBAGENT_COPY_ADMISSION.md create mode 100644 internal/runtime/delegation/ancestor_copy_admission.go create mode 100644 internal/runtime/delegation/ancestor_copy_admission_test.go create mode 100644 internal/runtime/delegation/task_ancestor_copy_admission_test.go create mode 100644 release-notes/studio/unreleased/14025-ancestor-copy-admission.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd1f56f8d0..1629740b3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,6 +170,43 @@ jobs: shell: pwsh run: Get-CimInstance Win32_Processor | Select-Object -ExpandProperty Name + - name: test (Windows ancestor copy admission) + if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' + timeout-minutes: 5 + shell: bash + run: | + set -o pipefail + go test -count=1 -timeout=3m -json -run '^(TestAncestorCopyAdmission|TestTaskAncestorCopyAdmission)' ./internal/runtime/delegation | tee "$RUNNER_TEMP/ancestor-copy-admission.jsonl" + jq -se ' + [.[] | select(.Test == null and .Action == "pass") | .Package] as $packages | + [.[] | select(.Test != null and .Action == "pass") | .Test] as $tests | + all(.[]; .Action != "skip" and .Action != "fail") and + (["reasonix/internal/runtime/delegation"] - $packages | length) == 0 and + (["TestAncestorCopyAdmissionCoversUnpublishedRun", + "TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_continue", + "TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_legacy", + "TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_continue", + "TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_legacy", + "TestAncestorCopyAdmissionAllowsDifferentParents", + "TestAncestorCopyAdmissionReleasesAbandonedPreparation", + "TestAncestorCopyAdmissionReleasesAfterInvalidCopy", + "TestAncestorCopyAdmissionAllowsDifferentSources", + "TestAncestorCopyAdmissionUsesNearestSourceIdentity", + "TestAncestorCopyAdmissionReleasesAfterBusyCurrentReference", + "TestTaskAncestorCopyAdmissionBeforeRunningMetadata", + "TestTaskAncestorCopyAdmissionBeforeRunningMetadata/continue_from", + "TestTaskAncestorCopyAdmissionBeforeRunningMetadata/fork_from"] - $tests | length) == 0 + ' "$RUNNER_TEMP/ancestor-copy-admission.jsonl" + + - name: upload Windows ancestor copy admission inventory + if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: ancestor-copy-admission-windows-${{ github.run_attempt }} + path: ${{ runner.temp }}/ancestor-copy-admission.jsonl + if-no-files-found: error + retention-days: 7 + - name: test if: env.RUN_STEPS == 'true' && runner.os != 'Windows' env: diff --git a/docs/SUBAGENT_COPY_ADMISSION.md b/docs/SUBAGENT_COPY_ADMISSION.md new file mode 100644 index 0000000000..ba6537395f --- /dev/null +++ b/docs/SUBAGENT_COPY_ADMISSION.md @@ -0,0 +1,56 @@ +--- +owner: @esengine +backup: @SivanCola +status: active +reviewed: 2026-10-28 +--- + +# Ancestor continuation admission / 祖先续跑准入 + +## Behavior / 行为 + +1. Within one `SubagentStore`, continuing an ancestor transcript reserves its + canonical nearest source and target parent session before searching for a + current-session copy. The reservation lasts until the returned run releases. +2. A competing continuation for that pair is refused as already in progress, + including before the first copy publishes its `running` metadata. It MUST + NOT create another copy or make another provider request. +3. `continue_from` and the accepted legacy `fork_from` input share this owner. + Referring to an older ancestor or its nearest lineage copy uses the same + reservation. Different target parents and different sources remain separate. +4. Failed preparation and abandoned unpublished runs release their reservation. + After a completed run releases, another continuation reuses its persisted + copy. Direct current-reference continuation keeps its existing ref lock. + +1. 同一 `SubagentStore` 在查找当前会话副本前,按最近祖先来源和目标父会话预留续跑归属;预留随返回的 run 一起释放。 +2. 首份副本尚未发布 `running` 元数据时,同一组合的竞争续跑也会被拒绝;不得再创建副本或发起模型请求。 +3. `continue_from` 与兼容的旧 `fork_from` 输入共用此归属。较早祖先及其最近副本归一到同一来源;不同目标会话、不同来源保持独立。 +4. 准备失败或放弃未发布的 run 会释放预留。已完成 run 释放后,续跑复用原持久化副本;直接按当前副本引用续跑仍使用原引用锁。 + +## Compatibility and limits / 兼容与边界 + +| Boundary / 边界 | Contract / 合同 | +| --- | --- | +| Storage / 存储 | No metadata, transcript, status or schema change. / 不改变元数据、转录、状态或 schema。 | +| Existing ambiguity / 既有歧义 | Multiple existing copies remain refused; no automatic selection or deletion. / 已有多个副本仍拒绝,不自动选择或删除。 | +| Scope / 范围 | One Store instance; existing process-local locking contract. / 仅同一 Store 实例,沿用进程内锁合同。 | +| Cross-version writers / 跨版本写入 | No cross-process or old-writer fence; old binaries retain their behavior. / 不提供跨进程或旧写入者隔离。 | +| Recovery / 恢复 | No resume of failed/interrupted children, durable reservation or restart recovery. / 不恢复失败或中断子任务,不提供持久化预留与重启恢复。 | +| Authority / 权限 | Tool permission, cancellation authority, provider prefix and tool schema are unchanged. / 工具权限、取消权限、模型前缀和工具 schema 不变。 | + +## Validation / 验证 + +1. Store regressions hold an unpublished preparation, cover both input routes, + nearest-source aliases, independent parents/sources and release after errors. +2. TaskTool regressions block the existing parent-destruction callback before + `MarkRunning` writes metadata. A competing call is refused; the first call + receives the real persisted source transcript and creates the only copy. +3. These are local Store/TaskTool tests with a scripted provider, not a live + provider, full Boot, browser, crash/restart or physical-process-scope test. +4. The Windows workflow requires every named regression and the package terminal + pass, with no skips. Local cross-compilation is not native Windows evidence. + +1. Store 回归保留未发布的准备结果,覆盖两种输入、最近来源别名、不同会话和来源,以及错误后的释放。 +2. TaskTool 回归在 `MarkRunning` 写元数据前阻塞既有父会话销毁检查回调。竞争调用被拒绝,首个调用读取真实持久化来源转录并创建唯一副本。 +3. 以上是本机 Store/TaskTool 与脚本 provider 验证,不是线上 provider、完整 Boot、浏览器、崩溃重启或物理进程范围验证。 +4. Windows 工作流要求全部指定测试及包终态通过且零跳过;本机交叉编译不等于 Windows 原生执行。 diff --git a/internal/runtime/delegation/ancestor_copy_admission.go b/internal/runtime/delegation/ancestor_copy_admission.go new file mode 100644 index 0000000000..0238d26921 --- /dev/null +++ b/internal/runtime/delegation/ancestor_copy_admission.go @@ -0,0 +1,58 @@ +package delegation + +import ( + "fmt" + "strings" +) + +type ancestorCopyKey struct { + parent string + source string +} + +// A copied run has no durable metadata until its caller publishes it. Reserve +// the target-parent/source pair before looking for an existing copy, and keep +// that reservation for the returned run's ordinary lifetime. The ref lock alone +// cannot protect this gap because each preparation would mint a different ref. +func (s *SubagentStore) prepareContinueFromAncestor(ref string, spec SubagentSpec) (*SubagentRun, error) { + source, err := s.nearestLineageSource(ref, spec) + if err != nil { + return nil, err + } + releaseCopy, err := s.reserveAncestorCopy(ancestorCopyKey{ + parent: strings.TrimSpace(spec.ParentSession), source: source, + }) + if err != nil { + return nil, err + } + run, err := s.prepareContinueFromSource(source, spec) + if err != nil { + releaseCopy() + return nil, err + } + releaseRun := run.release + run.release = func() { + if releaseRun != nil { + releaseRun() + } + releaseCopy() + } + return run, nil +} + +func (s *SubagentStore) reserveAncestorCopy(key ancestorCopyKey) (func(), error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.copying[key] { + return nil, fmt.Errorf("subagent reference %q is already being continued in parent session %q; retry after it finishes", key.source, key.parent) + } + if s.copying == nil { + s.copying = make(map[ancestorCopyKey]bool) + } + s.copying[key] = true + return func() { + s.mu.Lock() + delete(s.copying, key) + s.mu.Unlock() + }, nil +} diff --git a/internal/runtime/delegation/ancestor_copy_admission_test.go b/internal/runtime/delegation/ancestor_copy_admission_test.go new file mode 100644 index 0000000000..c0f8a01286 --- /dev/null +++ b/internal/runtime/delegation/ancestor_copy_admission_test.go @@ -0,0 +1,235 @@ +package delegation + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "reasonix/internal/contract/provider" + "reasonix/internal/state/sessionstore" +) + +func ancestorCopyFixture(t *testing.T) (*SubagentStore, string, SubagentSpec) { + t.Helper() + dir := t.TempDir() + s := NewSubagentStore(filepath.Join(dir, "subagents")) + spec := testSubagentSpec(t, "review") + spec.ParentSession = "root" + source, err := s.PrepareFresh(spec) + if err != nil { + t.Fatal(err) + } + defer source.Release() + source.Session.Add(provider.Message{Role: provider.RoleUser, Content: "original transcript bytes"}) + if err := s.SaveCompleted(source); err != nil { + t.Fatal(err) + } + saveTestBranchMeta(t, dir, "root", "") + saveTestBranchMeta(t, dir, "child", "root") + saveTestBranchMeta(t, dir, "sibling", "root") + spec.ParentSession = "child" + return s, source.Ref, spec +} + +func TestAncestorCopyAdmissionCoversUnpublishedRun(t *testing.T) { + for _, firstLegacy := range []bool{false, true} { + for _, secondLegacy := range []bool{false, true} { + firstName, secondName := "continue", "continue" + if firstLegacy { + firstName = "legacy" + } + if secondLegacy { + secondName = "legacy" + } + t.Run(firstName+"_then_"+secondName, func(t *testing.T) { + s, source, spec := ancestorCopyFixture(t) + prepare := func(legacy bool) (*SubagentRun, error) { + if legacy { + return s.PrepareLegacyForkFrom(source, spec) + } + return s.PrepareContinue(source, spec) + } + first, err := prepare(firstLegacy) + if err != nil { + t.Fatal(err) + } + defer first.Release() + if _, err := os.Stat(s.metaPath(first.Ref)); !os.IsNotExist(err) { + t.Fatalf("preparation published metadata: %v", err) + } + second, err := prepare(secondLegacy) + if second != nil { + second.Release() + } + if err == nil || !strings.Contains(err.Error(), "already") { + t.Fatalf("second admission = %v; first copy has not been published", err) + } + if err := s.SaveCompleted(first); err != nil { + t.Fatal(err) + } + first.Release() + reused, err := prepare(secondLegacy) + if err != nil { + t.Fatal(err) + } + defer reused.Release() + if reused.Ref != first.Ref { + t.Fatalf("continued %s instead of original copy %s", reused.Ref, first.Ref) + } + }) + } + } +} + +func TestAncestorCopyAdmissionAllowsDifferentParents(t *testing.T) { + s, source, spec := ancestorCopyFixture(t) + first, err := s.PrepareContinue(source, spec) + if err != nil { + t.Fatal(err) + } + defer first.Release() + spec.ParentSession = "sibling" + second, err := s.PrepareContinue(source, spec) + if err != nil { + t.Fatalf("independent target parent blocked: %v", err) + } + defer second.Release() + if first.Ref == second.Ref || second.Meta.ParentSession != "sibling" { + t.Fatal("independent target parent did not get its own copy") + } +} + +func TestAncestorCopyAdmissionReleasesAbandonedPreparation(t *testing.T) { + s, source, spec := ancestorCopyFixture(t) + first, err := s.PrepareContinue(source, spec) + if err != nil { + t.Fatal(err) + } + first.Release() + first.Release() + second, err := s.PrepareContinue(source, spec) + if err != nil { + t.Fatalf("abandoned preparation retained admission: %v", err) + } + defer second.Release() + if second.Ref == first.Ref { + t.Fatal("unpublished preparation was adopted as persisted state") + } +} + +func TestAncestorCopyAdmissionReleasesAfterInvalidCopy(t *testing.T) { + s, source, spec := ancestorCopyFixture(t) + copy, err := s.PrepareFresh(spec) + if err != nil { + t.Fatal(err) + } + copy.Meta.ForkedFrom = source + if err := s.SaveFailed(copy); err != nil { + t.Fatal(err) + } + copy.Release() + if run, err := s.PrepareContinue(source, spec); err == nil || !strings.Contains(err.Error(), "failed") { + if run != nil { + run.Release() + } + t.Fatalf("failed existing copy admission = %v", err) + } + // Repair only the fixture, to check that the failed lookup released its + // transient reservation. Production does not recover failed transcripts. + meta, err := s.LoadMeta(copy.Ref) + if err != nil { + t.Fatal(err) + } + meta.Status = sessionstore.SubagentCompleted + if err := s.saveMeta(meta); err != nil { + t.Fatal(err) + } + run, err := s.PrepareContinue(source, spec) + if err != nil { + t.Fatalf("failed preparation retained admission: %v", err) + } + run.Release() +} + +func TestAncestorCopyAdmissionAllowsDifferentSources(t *testing.T) { + s, source, spec := ancestorCopyFixture(t) + otherSpec := spec + otherSpec.ParentSession = "root" + other, err := s.PrepareFresh(otherSpec) + if err != nil { + t.Fatal(err) + } + if err := s.SaveCompleted(other); err != nil { + t.Fatal(err) + } + other.Release() + first, err := s.PrepareContinue(source, spec) + if err != nil { + t.Fatal(err) + } + defer first.Release() + second, err := s.PrepareContinue(other.Ref, spec) + if err != nil { + t.Fatalf("different ancestor source was blocked: %v", err) + } + second.Release() +} + +func TestAncestorCopyAdmissionUsesNearestSourceIdentity(t *testing.T) { + s, source, spec := ancestorCopyFixture(t) + child, err := s.PrepareContinue(source, spec) + if err != nil { + t.Fatal(err) + } + if err := s.SaveCompleted(child); err != nil { + t.Fatal(err) + } + child.Release() + saveTestBranchMeta(t, filepath.Dir(s.dir), "grandchild", "child") + spec.ParentSession = "grandchild" + first, err := s.PrepareContinue(source, spec) + if err != nil { + t.Fatal(err) + } + defer first.Release() + second, err := s.PrepareContinue(child.Ref, spec) + if second != nil { + second.Release() + } + if err == nil || !strings.Contains(err.Error(), "already") { + t.Fatalf("alias of nearest ancestor bypassed admission: %v", err) + } +} + +func TestAncestorCopyAdmissionReleasesAfterBusyCurrentReference(t *testing.T) { + s, source, spec := ancestorCopyFixture(t) + copy, err := s.PrepareContinue(source, spec) + if err != nil { + t.Fatal(err) + } + if err := s.SaveCompleted(copy); err != nil { + t.Fatal(err) + } + copy.Release() + direct, err := s.PrepareContinue(copy.Ref, spec) + if err != nil { + t.Fatal(err) + } + defer direct.Release() + if run, err := s.PrepareContinue(source, spec); err == nil || !strings.Contains(err.Error(), "already") { + if run != nil { + run.Release() + } + t.Fatalf("busy current copy admission = %v", err) + } + direct.Release() + viaAncestor, err := s.PrepareContinue(source, spec) + if err != nil { + t.Fatalf("busy-ref failure retained ancestor reservation: %v", err) + } + defer viaAncestor.Release() + if viaAncestor.Ref != copy.Ref { + t.Fatal("ancestor retry created another copy") + } +} diff --git a/internal/runtime/delegation/subagent_store.go b/internal/runtime/delegation/subagent_store.go index cb60c5f93a..843706b498 100644 --- a/internal/runtime/delegation/subagent_store.go +++ b/internal/runtime/delegation/subagent_store.go @@ -85,8 +85,9 @@ type SubagentStore struct { // cleanupBeforeReread is a test seam for deterministic lease interleavings. cleanupBeforeReread func(parentSession, ref string) - mu sync.Mutex - locked map[string]bool + mu sync.Mutex + locked map[string]bool + copying map[ancestorCopyKey]bool // live holds the conversation of every run this process is executing, so a // reader sees a child while it works: its file is written only at the end. live map[string]*liveRun @@ -376,11 +377,7 @@ func (s *SubagentStore) PrepareLegacyForkFrom(ref string, spec SubagentSpec) (*S return s.PrepareContinue(ref, spec) } -func (s *SubagentStore) prepareContinueFromAncestor(sourceRef string, spec SubagentSpec) (*SubagentRun, error) { - sourceRef, err := s.nearestLineageSource(sourceRef, spec) - if err != nil { - return nil, err - } +func (s *SubagentStore) prepareContinueFromSource(sourceRef string, spec SubagentSpec) (*SubagentRun, error) { copies, err := s.compatibleCopiesFromSource(sourceRef, spec) if err != nil { return nil, err diff --git a/internal/runtime/delegation/task_ancestor_copy_admission_test.go b/internal/runtime/delegation/task_ancestor_copy_admission_test.go new file mode 100644 index 0000000000..bb34e40cde --- /dev/null +++ b/internal/runtime/delegation/task_ancestor_copy_admission_test.go @@ -0,0 +1,95 @@ +package delegation + +import ( + "context" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "reasonix/internal/contract/provider" + "reasonix/internal/contract/tool" + "reasonix/internal/runtime/agent" + "reasonix/internal/state/sessionstore" +) + +func TestTaskAncestorCopyAdmissionBeforeRunningMetadata(t *testing.T) { + for _, route := range []string{"continue_from", "fork_from"} { + t.Run(route, func(t *testing.T) { + dir := t.TempDir() + store := NewSubagentStore(filepath.Join(dir, "subagents")) + prov := &mockProvider{name: "sub", chunks: []provider.Chunk{ + {Type: provider.ChunkText, Text: "persisted source answer"}, + {Type: provider.ChunkDone}, + }} + task := NewTaskTool(prov, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, "", "sys", nil, 0, "", "", nil). + WithTranscripts(store, t.TempDir(), "base-model", "base-effort") + rootCtx := agent.WithParentSession(context.Background(), "root") + out, err := task.Execute(rootCtx, []byte(`{"prompt":"source task"}`)) + if err != nil { + t.Fatal(err) + } + source := subagentRefFromOutput(t, out) + saveTestBranchMeta(t, dir, "root", "") + saveTestBranchMeta(t, dir, "child", "root") + + entered, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + var probes atomic.Int32 + // This existing host callback is consulted by MarkRunning before it + // publishes metadata, after the real TaskTool prepared its transcript. + store.WithDestroyedChecker(func(parent string) bool { + if parent == "child" && probes.Add(1) == 1 { + close(entered) + <-release + } + return false + }) + childCtx := agent.WithParentSession(context.Background(), "child") + type result struct { + output string + err error + } + done := make(chan result, 1) + go func() { + output, err := task.Execute(childCtx, []byte(`{"prompt":"first child","continue_from":"`+source+`"}`)) + done <- result{output, err} + }() + defer unblock() + select { + case <-entered: + case <-time.After(10 * time.Second): + t.Fatal("first TaskTool call did not reach metadata publication") + } + _, secondErr := task.Execute(childCtx, []byte(`{"prompt":"competing child","`+route+`":"`+source+`"}`)) + unblock() + first := <-done + if first.err != nil { + t.Fatalf("first TaskTool: %v", first.err) + } + if secondErr == nil || !strings.Contains(secondErr.Error(), "already") { + t.Errorf("second TaskTool admission = %v; want busy refusal", secondErr) + } + copies, err := sessionstore.ListSubagentsByParent(dir, "child") + if err != nil { + t.Fatal(err) + } + if len(copies) != 1 || copies[0].Ref != subagentRefFromOutput(t, first.output) { + t.Errorf("current-parent copies = %d; want only the first admitted run", len(copies)) + } + if len(prov.requests) != 2 { + t.Errorf("provider requests = %d; want source and first child only", len(prov.requests)) + } + found := false + for _, message := range prov.lastReq.Messages { + found = found || message.Content == "persisted source answer" + } + if !found { + t.Fatal("child did not receive the persisted source transcript") + } + }) + } +} diff --git a/release-notes/studio/unreleased/14025-ancestor-copy-admission.md b/release-notes/studio/unreleased/14025-ancestor-copy-admission.md new file mode 100644 index 0000000000..b6b0c0e569 --- /dev/null +++ b/release-notes/studio/unreleased/14025-ancestor-copy-admission.md @@ -0,0 +1,15 @@ +同一会话并发续跑祖先子任务时,不再重复创建尚未发布的转录副本。 + +## 修复 + +- 在现有 Store 内预留目标会话与祖先来源,覆盖首次复制到发布元数据之间的窗口。关联 #14025。 +- 竞争续跑返回忙碌提示;不同会话和来源仍独立,已完成副本继续复用,不改变存储格式或工具权限。关联 #14025。 + +--- + + + +## Fixed + +- Reserve ancestor continuation copies within one Store before metadata publication, preventing competing calls from creating duplicate transcripts. Refs #14025. +- Busy continuations are refused; independent parents and sources remain separate. Completed copies are reused without storage-format or permission changes. Refs #14025. From 8378f0834cad8e23c75843578c43bef5bab0ecfc Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:03:31 +0800 Subject: [PATCH 2/3] fix(delegation): identify busy ancestor continuations and share Windows checks --- .../ancestor-copy-admission.json | 24 ++++++ .github/workflows/ci.yml | 32 ++------ docs/SUBAGENT_COPY_ADMISSION.md | 21 +++++ .../delegation/ancestor_copy_admission.go | 7 +- .../ancestor_copy_admission_test.go | 6 +- internal/runtime/delegation/task.go | 3 + .../task_ancestor_copy_admission_test.go | 49 ++++++++++- .../14025-ancestor-copy-admission.md | 2 + scripts/windows-concurrency-tests.mjs | 81 +++++++++++++++++++ scripts/windows-concurrency-tests.test.mjs | 43 ++++++++++ 10 files changed, 238 insertions(+), 30 deletions(-) create mode 100644 .github/windows-concurrency/ancestor-copy-admission.json create mode 100644 scripts/windows-concurrency-tests.mjs create mode 100644 scripts/windows-concurrency-tests.test.mjs diff --git a/.github/windows-concurrency/ancestor-copy-admission.json b/.github/windows-concurrency/ancestor-copy-admission.json new file mode 100644 index 0000000000..1276933c99 --- /dev/null +++ b/.github/windows-concurrency/ancestor-copy-admission.json @@ -0,0 +1,24 @@ +[ + { + "package": "reasonix/internal/runtime/delegation", + "tests": [ + "TestAncestorCopyAdmissionCoversUnpublishedRun", + "TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_continue", + "TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_legacy", + "TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_continue", + "TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_legacy", + "TestAncestorCopyAdmissionAllowsDifferentParents", + "TestAncestorCopyAdmissionReleasesAbandonedPreparation", + "TestAncestorCopyAdmissionReleasesAfterInvalidCopy", + "TestAncestorCopyAdmissionAllowsDifferentSources", + "TestAncestorCopyAdmissionUsesNearestSourceIdentity", + "TestAncestorCopyAdmissionReleasesAfterBusyCurrentReference", + "TestTaskAncestorCopyAdmissionBeforeRunningMetadata", + "TestTaskAncestorCopyAdmissionBeforeRunningMetadata/continue_from", + "TestTaskAncestorCopyAdmissionBeforeRunningMetadata/fork_from", + "TestTaskAncestorCopyAdmissionReleasesUnstartedBackground", + "TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/continue_from", + "TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/fork_from" + ] + } +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1629740b3b..2b4f2f8544 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,40 +170,20 @@ jobs: shell: pwsh run: Get-CimInstance Win32_Processor | Select-Object -ExpandProperty Name - - name: test (Windows ancestor copy admission) + - name: test (Windows concurrency contracts) if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' timeout-minutes: 5 shell: bash run: | - set -o pipefail - go test -count=1 -timeout=3m -json -run '^(TestAncestorCopyAdmission|TestTaskAncestorCopyAdmission)' ./internal/runtime/delegation | tee "$RUNNER_TEMP/ancestor-copy-admission.jsonl" - jq -se ' - [.[] | select(.Test == null and .Action == "pass") | .Package] as $packages | - [.[] | select(.Test != null and .Action == "pass") | .Test] as $tests | - all(.[]; .Action != "skip" and .Action != "fail") and - (["reasonix/internal/runtime/delegation"] - $packages | length) == 0 and - (["TestAncestorCopyAdmissionCoversUnpublishedRun", - "TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_continue", - "TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_legacy", - "TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_continue", - "TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_legacy", - "TestAncestorCopyAdmissionAllowsDifferentParents", - "TestAncestorCopyAdmissionReleasesAbandonedPreparation", - "TestAncestorCopyAdmissionReleasesAfterInvalidCopy", - "TestAncestorCopyAdmissionAllowsDifferentSources", - "TestAncestorCopyAdmissionUsesNearestSourceIdentity", - "TestAncestorCopyAdmissionReleasesAfterBusyCurrentReference", - "TestTaskAncestorCopyAdmissionBeforeRunningMetadata", - "TestTaskAncestorCopyAdmissionBeforeRunningMetadata/continue_from", - "TestTaskAncestorCopyAdmissionBeforeRunningMetadata/fork_from"] - $tests | length) == 0 - ' "$RUNNER_TEMP/ancestor-copy-admission.jsonl" + node --test scripts/windows-concurrency-tests.test.mjs + node scripts/windows-concurrency-tests.mjs "$RUNNER_TEMP/concurrency-windows.jsonl" - - name: upload Windows ancestor copy admission inventory + - name: upload Windows concurrency inventory if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: - name: ancestor-copy-admission-windows-${{ github.run_attempt }} - path: ${{ runner.temp }}/ancestor-copy-admission.jsonl + name: concurrency-windows-${{ github.run_attempt }} + path: ${{ runner.temp }}/concurrency-windows.jsonl if-no-files-found: error retention-days: 7 diff --git a/docs/SUBAGENT_COPY_ADMISSION.md b/docs/SUBAGENT_COPY_ADMISSION.md index ba6537395f..0bc134643a 100644 --- a/docs/SUBAGENT_COPY_ADMISSION.md +++ b/docs/SUBAGENT_COPY_ADMISSION.md @@ -27,6 +27,13 @@ reviewed: 2026-10-28 3. `continue_from` 与兼容的旧 `fork_from` 输入共用此归属。较早祖先及其最近副本归一到同一来源;不同目标会话、不同来源保持独立。 4. 准备失败或放弃未发布的 run 会释放预留。已完成 run 释放后,续跑复用原持久化副本;直接按当前副本引用续跑仍使用原引用锁。 +`errors.Is(err, ErrAncestorCopyBusy)` identifies the refusal through wrapping. +TaskTool uses the existing `subagent.settling` answer code and preserves that +sentinel; callers do not classify error prose. + +包装后的错误仍可用 `errors.Is(err, ErrAncestorCopyBusy)` 识别;TaskTool 使用既有 +`subagent.settling` 回答码并保留此标识,不解析错误文案。 + ## Compatibility and limits / 兼容与边界 | Boundary / 边界 | Contract / 合同 | @@ -54,3 +61,17 @@ reviewed: 2026-10-28 2. TaskTool 回归在 `MarkRunning` 写元数据前阻塞既有父会话销毁检查回调。竞争调用被拒绝,首个调用读取真实持久化来源转录并创建唯一副本。 3. 以上是本机 Store/TaskTool 与脚本 provider 验证,不是线上 provider、完整 Boot、浏览器、崩溃重启或物理进程范围验证。 4. Windows 工作流要求全部指定测试及包终态通过且零跳过;本机交叉编译不等于 Windows 原生执行。 + +A second TaskTool regression requests background execution without a job manager. +Preparation succeeds, then normal completion abandons the unstarted run. +No test calls `Release` for it; a foreground retry proves admission was freed. + +另一 TaskTool 回归在没有 job manager 时请求后台执行:准备成功后由正常结束路径 +放弃尚未启动的 run;测试不调用其 `Release`,前台重试证明预留已释放。 + +The shared `scripts/windows-concurrency-tests.mjs` step reads additive, +package-qualified inventories under `.github/windows-concurrency/`. This suite +requires 17 named passes including parents. The broad smoke remains. + +共享脚本读取 `.github/windows-concurrency/` 中按包限定的独立清单;此套件要求含 +父测试的 17 个指定测试通过,保留 broad smoke。 diff --git a/internal/runtime/delegation/ancestor_copy_admission.go b/internal/runtime/delegation/ancestor_copy_admission.go index 0238d26921..028e379cbf 100644 --- a/internal/runtime/delegation/ancestor_copy_admission.go +++ b/internal/runtime/delegation/ancestor_copy_admission.go @@ -1,10 +1,15 @@ package delegation import ( + "errors" "fmt" "strings" ) +// ErrAncestorCopyBusy means this store already holds a continuation of the +// same canonical source in the target parent. Callers may retry after release. +var ErrAncestorCopyBusy = errors.New("ancestor continuation is already in progress") + type ancestorCopyKey struct { parent string source string @@ -44,7 +49,7 @@ func (s *SubagentStore) reserveAncestorCopy(key ancestorCopyKey) (func(), error) s.mu.Lock() defer s.mu.Unlock() if s.copying[key] { - return nil, fmt.Errorf("subagent reference %q is already being continued in parent session %q; retry after it finishes", key.source, key.parent) + return nil, fmt.Errorf("%w: subagent reference %q in parent session %q; retry after it finishes", ErrAncestorCopyBusy, key.source, key.parent) } if s.copying == nil { s.copying = make(map[ancestorCopyKey]bool) diff --git a/internal/runtime/delegation/ancestor_copy_admission_test.go b/internal/runtime/delegation/ancestor_copy_admission_test.go index c0f8a01286..a6fc98a073 100644 --- a/internal/runtime/delegation/ancestor_copy_admission_test.go +++ b/internal/runtime/delegation/ancestor_copy_admission_test.go @@ -1,6 +1,8 @@ package delegation import ( + "errors" + "fmt" "os" "path/filepath" "strings" @@ -62,7 +64,7 @@ func TestAncestorCopyAdmissionCoversUnpublishedRun(t *testing.T) { if second != nil { second.Release() } - if err == nil || !strings.Contains(err.Error(), "already") { + if !errors.Is(fmt.Errorf("caller: %w", err), ErrAncestorCopyBusy) { t.Fatalf("second admission = %v; first copy has not been published", err) } if err := s.SaveCompleted(first); err != nil { @@ -197,7 +199,7 @@ func TestAncestorCopyAdmissionUsesNearestSourceIdentity(t *testing.T) { if second != nil { second.Release() } - if err == nil || !strings.Contains(err.Error(), "already") { + if !errors.Is(err, ErrAncestorCopyBusy) { t.Fatalf("alias of nearest ancestor bypassed admission: %v", err) } } diff --git a/internal/runtime/delegation/task.go b/internal/runtime/delegation/task.go index 61ee938e90..3090e256ee 100644 --- a/internal/runtime/delegation/task.go +++ b/internal/runtime/delegation/task.go @@ -593,6 +593,9 @@ func (t *TaskTool) runProfileSpec(ctx context.Context, spec ProfileExecSpec, exi agent.AttachReviewReport(subReg, grant) run, err := t.prepareTranscriptRunWithPrompt(withUpstream(ctx, spec.Context.Upstream), subReg, modelRef, effortRef, spec.Context.parentSession(ctx), execution, spec.Context.executionParent(execution), spec.Context.ContinueFrom, spec.Context.ForkFrom, spec.Worker.SystemPrompt, spec.Worker.Kind, spec.Worker.Name) if err != nil { + if errors.Is(err, ErrAncestorCopyBusy) { + return "", fmt.Errorf("%s: %w", steerCode(ErrSubagentSettling), err) + } return "", err } exit.run = run diff --git a/internal/runtime/delegation/task_ancestor_copy_admission_test.go b/internal/runtime/delegation/task_ancestor_copy_admission_test.go index bb34e40cde..dc5bced31e 100644 --- a/internal/runtime/delegation/task_ancestor_copy_admission_test.go +++ b/internal/runtime/delegation/task_ancestor_copy_admission_test.go @@ -2,6 +2,7 @@ package delegation import ( "context" + "errors" "path/filepath" "strings" "sync" @@ -70,7 +71,7 @@ func TestTaskAncestorCopyAdmissionBeforeRunningMetadata(t *testing.T) { if first.err != nil { t.Fatalf("first TaskTool: %v", first.err) } - if secondErr == nil || !strings.Contains(secondErr.Error(), "already") { + if !errors.Is(secondErr, ErrAncestorCopyBusy) || !strings.HasPrefix(secondErr.Error(), "subagent.settling: ") { t.Errorf("second TaskTool admission = %v; want busy refusal", secondErr) } copies, err := sessionstore.ListSubagentsByParent(dir, "child") @@ -93,3 +94,49 @@ func TestTaskAncestorCopyAdmissionBeforeRunningMetadata(t *testing.T) { }) } } + +func TestTaskAncestorCopyAdmissionReleasesUnstartedBackground(t *testing.T) { + for _, route := range []string{"continue_from", "fork_from"} { + t.Run(route, func(t *testing.T) { + dir := t.TempDir() + store := NewSubagentStore(filepath.Join(dir, "subagents")) + prov := &mockProvider{name: "sub", chunks: []provider.Chunk{ + {Type: provider.ChunkText, Text: "persisted source answer"}, + {Type: provider.ChunkDone}, + }} + task := NewTaskTool(prov, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, "", "sys", nil, 0, "", "", nil). + WithTranscripts(store, t.TempDir(), "base-model", "base-effort") + root := agent.WithParentSession(context.Background(), "root") + out, err := task.Execute(root, []byte(`{"prompt":"source task"}`)) + if err != nil { + t.Fatal(err) + } + source := subagentRefFromOutput(t, out) + saveTestBranchMeta(t, dir, "root", "") + saveTestBranchMeta(t, dir, "child", "root") + child := agent.WithParentSession(context.Background(), "child") + // TaskTool prepares the transcript before looking up the job manager. + // With no manager, its normal completion owner abandons that run before + // MarkRunning or a provider call. The test never calls run.Release. + _, err = task.Execute(child, []byte(`{"prompt":"unstarted child","run_in_background":true,"`+route+`":"`+source+`"}`)) + if err == nil || err.Error() != "background execution is not available in this context" { + t.Fatalf("unstarted background result = %v", err) + } + if len(prov.requests) != 1 { + t.Fatal("abandoned child reached the provider") + } + copies, err := sessionstore.ListSubagentsByParent(dir, "child") + if err != nil || len(copies) != 0 { + t.Fatalf("abandoned run published metadata: %v, %v", copies, err) + } + out, err = task.Execute(child, []byte(`{"prompt":"retry in foreground","`+route+`":"`+source+`"}`)) + if err != nil { + t.Fatalf("normal abandonment retained the reservation: %v", err) + } + copies, err = sessionstore.ListSubagentsByParent(dir, "child") + if err != nil || len(copies) != 1 || copies[0].Ref != subagentRefFromOutput(t, out) || len(prov.requests) != 2 { + t.Fatalf("foreground retry: copies=%v error=%v requests=%d", copies, err, len(prov.requests)) + } + }) + } +} diff --git a/release-notes/studio/unreleased/14025-ancestor-copy-admission.md b/release-notes/studio/unreleased/14025-ancestor-copy-admission.md index b6b0c0e569..3f0f7a3321 100644 --- a/release-notes/studio/unreleased/14025-ancestor-copy-admission.md +++ b/release-notes/studio/unreleased/14025-ancestor-copy-admission.md @@ -4,6 +4,7 @@ - 在现有 Store 内预留目标会话与祖先来源,覆盖首次复制到发布元数据之间的窗口。关联 #14025。 - 竞争续跑返回忙碌提示;不同会话和来源仍独立,已完成副本继续复用,不改变存储格式或工具权限。关联 #14025。 +- 忙碌拒绝保留可识别的错误标识;尚未启动的后台准备被放弃后,正常退出路径释放预留,允许前台重试。关联 #14025。 --- @@ -13,3 +14,4 @@ - Reserve ancestor continuation copies within one Store before metadata publication, preventing competing calls from creating duplicate transcripts. Refs #14025. - Busy continuations are refused; independent parents and sources remain separate. Completed copies are reused without storage-format or permission changes. Refs #14025. +- Busy refusals retain a typed error identity; normal exit releases an abandoned, unstarted background preparation so a foreground retry can proceed. Refs #14025. diff --git a/scripts/windows-concurrency-tests.mjs b/scripts/windows-concurrency-tests.mjs new file mode 100644 index 0000000000..7185a1028e --- /dev/null +++ b/scripts/windows-concurrency-tests.mjs @@ -0,0 +1,81 @@ +// Focused Windows concurrency contracts live in additive, package-qualified +// inventories. New regressions do not need another workflow step or verifier. +import assert from 'node:assert/strict'; +import { closeSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +export function readInventory(directory) { + const files = readdirSync(directory).filter(name => name.endsWith('.json')).sort(); + assert(files.length > 0, 'no Windows concurrency inventories'); + const required = new Map(); + for (const file of files) { + const groups = JSON.parse(readFileSync(join(directory, file), 'utf8')); + assert(Array.isArray(groups) && groups.length > 0, `${file}: empty inventory`); + for (const group of groups) { + assert(group && /^reasonix\/internal\/(?:[a-z0-9_]+\/)*[a-z0-9_]+$/.test(group.package), `${file}: invalid package`); + assert(Array.isArray(group.tests) && group.tests.length > 0, `${file}: empty tests`); + const tests = required.get(group.package) ?? new Set(); + for (const test of group.tests) { + assert(typeof test === 'string' && /^Test[A-Za-z0-9_]+(?:\/[^\s]+)*$/.test(test), `${file}: invalid test`); + tests.add(test); + } + required.set(group.package, tests); + } + } + for (const tests of required.values()) { + for (const test of tests) assert(tests.has(test.split('/')[0]), `missing parent for ${test}`); + } + return required; +} + +export function verifyInventory(required, raw) { + const passed = new Map(); + const packages = new Set(); + for (const line of raw.split(/\r?\n/).filter(line => line.trim())) { + const event = JSON.parse(line); + assert(required.has(event.Package), `unexpected package: ${event.Package}`); + assert(!['skip', 'fail'].includes(event.Action), `${event.Package}: ${event.Action}: ${event.Test ?? '(package)'}`); + if (event.Action !== 'pass') continue; + if (!event.Test) { + packages.add(event.Package); + continue; + } + const tests = passed.get(event.Package) ?? new Set(); + assert(!tests.has(event.Test), `duplicate pass: ${event.Package}/${event.Test}`); + tests.add(event.Test); + passed.set(event.Package, tests); + } + let count = 0; + for (const [pkg, tests] of required) { + assert(packages.has(pkg), `missing package terminal: ${pkg}`); + for (const test of tests) { + assert(passed.get(pkg)?.has(test), `missing test: ${pkg}/${test}`); + count++; + } + } + return { requiredTests: count, packages: required.size }; +} + +function main() { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const required = readInventory(join(root, '.github/windows-concurrency')); + const roots = [...new Set([...required.values()].flatMap(tests => [...tests].map(test => test.split('/')[0])))].sort(); + const output = resolve(process.argv[2] ?? join(process.env.RUNNER_TEMP ?? '.', 'concurrency-windows.jsonl')); + mkdirSync(dirname(output), { recursive: true }); + const fd = openSync(output, 'w'); + let result; + try { + result = spawnSync('go', ['test', '-count=1', '-timeout=3m', '-json', '-run', `^(${roots.join('|')})$`, ...required.keys()], { + cwd: root, stdio: ['ignore', fd, 'inherit'], + }); + } finally { + closeSync(fd); + } + assert(!result.error, result.error?.message); + assert.equal(result.status, 0, `go test failed (${result.signal ?? result.status}); see ${output}`); + console.log(JSON.stringify({ ...verifyInventory(required, readFileSync(output, 'utf8')), output })); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/windows-concurrency-tests.test.mjs b/scripts/windows-concurrency-tests.test.mjs new file mode 100644 index 0000000000..e774a2d389 --- /dev/null +++ b/scripts/windows-concurrency-tests.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { readInventory, verifyInventory } from './windows-concurrency-tests.mjs'; + +test('additive manifests keep package-qualified requirements', () => { + const dir = mkdtempSync(join(tmpdir(), 'concurrency-inventory-')); + try { + const a = 'reasonix/internal/first', b = 'reasonix/internal/second'; + writeFileSync(join(dir, 'one.json'), JSON.stringify([{ package: a, tests: ['TestShared', 'TestShared/case'] }])); + writeFileSync(join(dir, 'two.json'), JSON.stringify([{ package: b, tests: ['TestShared'] }])); + const required = readInventory(dir); + const events = [ + { Package: a, Action: 'pass', Test: 'TestShared/case' }, + { Package: a, Action: 'pass', Test: 'TestShared' }, + { Package: a, Action: 'pass' }, + { Package: b, Action: 'pass', Test: 'TestShared' }, + { Package: b, Action: 'pass' }, + ]; + const raw = values => values.map(value => JSON.stringify(value)).join('\r\n'); + assert.deepEqual(verifyInventory(required, raw(events)), { requiredTests: 3, packages: 2 }); + for (let i = 0; i < events.length; i++) { + assert.throws(() => verifyInventory(required, raw(events.filter((_, index) => index !== i)))); + assert.throws(() => verifyInventory(required, raw(events.map((event, index) => index === i ? { ...event, Action: 'skip' } : event)))); + } + assert.throws(() => verifyInventory(required, raw([...events, { Package: a, Action: 'fail' }]))); + assert.throws(() => verifyInventory(required, raw([...events, events[0]]))); + assert.throws(() => verifyInventory(required, '{')); + writeFileSync(join(dir, 'two.json'), JSON.stringify([{ package: b, tests: ['TestOrphan/case'] }])); + assert.throws(() => readInventory(dir)); + writeFileSync(join(dir, 'two.json'), JSON.stringify([{ package: '../other', tests: ['TestShared'] }])); + assert.throws(() => readInventory(dir)); + writeFileSync(join(dir, 'two.json'), '[]'); + assert.throws(() => readInventory(dir)); + rmSync(join(dir, 'one.json')); + rmSync(join(dir, 'two.json')); + assert.throws(() => readInventory(dir)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 382016dfe116aa8a14c923ac49bcc4f2e0f258bd Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:18:44 +0800 Subject: [PATCH 3/3] fix(ci): avoid backtracking in concurrency inventory names Problem: CodeQL reports exponential backtracking in the shared Windows inventory test-name parser. Root cause: repeated suffix groups can partition the same slash sequence. Fix: accept the entire optional non-whitespace suffix once, preserving the previous accepted language. Cover nested names and a long malformed suffix. Verification: two Node tests pass; the original regex fails the bounded regression while the existing contract test passes. Go and inventory bytes are unchanged; prior exact-head Go qualification remains separate. --- scripts/windows-concurrency-tests.mjs | 2 +- scripts/windows-concurrency-tests.test.mjs | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/scripts/windows-concurrency-tests.mjs b/scripts/windows-concurrency-tests.mjs index 7185a1028e..0c39d21277 100644 --- a/scripts/windows-concurrency-tests.mjs +++ b/scripts/windows-concurrency-tests.mjs @@ -18,7 +18,7 @@ export function readInventory(directory) { assert(Array.isArray(group.tests) && group.tests.length > 0, `${file}: empty tests`); const tests = required.get(group.package) ?? new Set(); for (const test of group.tests) { - assert(typeof test === 'string' && /^Test[A-Za-z0-9_]+(?:\/[^\s]+)*$/.test(test), `${file}: invalid test`); + assert(typeof test === 'string' && /^Test[A-Za-z0-9_]+(?:\/[^\s]+)?$/.test(test), `${file}: invalid test`); tests.add(test); } required.set(group.package, tests); diff --git a/scripts/windows-concurrency-tests.test.mjs b/scripts/windows-concurrency-tests.test.mjs index e774a2d389..fab3f327db 100644 --- a/scripts/windows-concurrency-tests.test.mjs +++ b/scripts/windows-concurrency-tests.test.mjs @@ -2,9 +2,30 @@ import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; import test from 'node:test'; import { readInventory, verifyInventory } from './windows-concurrency-tests.mjs'; +test('long invalid subtest suffix is rejected without repeated slash partitioning', () => { + const dir = mkdtempSync(join(tmpdir(), 'concurrency-inventory-')); + try { + const inventory = tests => JSON.stringify([{ package: 'reasonix/internal/example', tests }]); + const file = join(dir, 'names.json'); + const valid = ['Test0', 'Test0/a/b', 'Test0/!/', 'Test0//']; + writeFileSync(file, inventory(valid)); + assert.deepEqual([...readInventory(dir).values()][0], new Set(valid)); + writeFileSync(file, inventory(['Test0', `Test0/${'!/'.repeat(10000)} `])); + const script = `import assert from 'node:assert/strict'; + import { readInventory } from ${JSON.stringify(new URL('./windows-concurrency-tests.mjs', import.meta.url).href)}; + assert.throws(() => readInventory(process.argv[1]), /invalid test/);`; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', script, dir], { timeout: 5000, encoding: 'utf8' }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('additive manifests keep package-qualified requirements', () => { const dir = mkdtempSync(join(tmpdir(), 'concurrency-inventory-')); try {