diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19fb2134f9..9bcb951ee5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -639,6 +639,38 @@ jobs: if-no-files-found: ignore retention-days: 7 + - name: test (Windows explicit skill root budget) + 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 '^(TestProfileRootBudgetLifetime|TestProfileBudgetPreparationUsesTurnAdmission|TestEffectExplicitSkillRootBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/explicit-skill-root-budget.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/session/control", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and + (["TestProfileRootBudgetLifetime", "TestProfileRootBudgetLifetime/new_task", + "TestProfileRootBudgetLifetime/same_scope", "TestProfileRootBudgetLifetime/new_scope", + "TestProfileRootBudgetLifetime/reset", "TestProfileRootBudgetLifetime/new_session", + "TestProfileRootBudgetLifetime/inherited", "TestProfileRootBudgetLifetime/context_zero", + "TestProfileBudgetPreparationUsesTurnAdmission", "TestEffectExplicitSkillRootBudget", + "TestEffectExplicitSkillRootBudget/profile", "TestEffectExplicitSkillRootBudget/preview", + "TestEffectExplicitSkillRootBudget/slash", "TestEffectExplicitSkillRootBudget/chips", + "TestEffectExplicitSkillRootBudget/goal", "TestEffectExplicitSkillRootBudget/context_zero", + "TestEffectExplicitSkillRootBudget/unconfigured"] - $tests | length) == 0 + ' "$RUNNER_TEMP/explicit-skill-root-budget.jsonl" + + - name: upload Windows explicit skill root budget inventory + if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: explicit-skill-root-budget-windows-${{ github.run_attempt }} + path: ${{ runner.temp }}/explicit-skill-root-budget.jsonl + if-no-files-found: ignore + retention-days: 7 + - name: test (Windows root triage budget) if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request' timeout-minutes: 5 diff --git a/docs/EXPLICIT_SKILL_ROOT_BUDGET.md b/docs/EXPLICIT_SKILL_ROOT_BUDGET.md new file mode 100644 index 0000000000..608d0192e3 --- /dev/null +++ b/docs/EXPLICIT_SKILL_ROOT_BUDGET.md @@ -0,0 +1,79 @@ +--- +owner: @SivanCola +backup: @esengine +status: active +reviewed: 2026-10-27 +--- + +# Explicit skill root budget / 显式技能根预算 + +## Behavior / 行为 + +Explicit subagent slash commands and composer selections share the executor's +existing token/wall root. Preparation occurs after checkpoint and blocking +prompt hooks. An active Goal uses its existing budget and usage recorder. + +显式子代理 slash 命令和 composer 选择共享 executor 已有的 token/wall 根预算。 +在检查点及可阻止提交的 hook 之后准备;活跃 Goal 使用已有预算和用量 recorder。 + +Synchronous CLI profile run and preview use the Controller's existing blocking +turn admission before root preparation. Busy, closed and draining controllers +refuse entry; Controller cancellation reaches the admitted profile. Existing +workspace activity and write-authority checks now apply to this entry too. + +同步 CLI profile run 和 preview 在准备根预算前走 Controller 已有同步准入。 +忙碌、关闭或 draining 时拒绝进入;Controller 取消传给已准入的 profile。 +这一入口也遵守已有工作区活动和写入 authority 检查。 + +## Lifetime and limits / 生命周期与边界 + +Same delivery scope retains accumulated spend. New tasks, sessions, scope IDs +and explicit budget resets start a fresh root. An inherited root remains the +owner; projecting an old context into a job retains the old counter without +copying its cancellation. Multiple selected skills share one root. + +同 delivery scope 保留累计用量;新任务、session、scope ID 和显式预算 reset +开始新根。继承的根保持所有权;旧 context 投射给 job 保留旧计数但不复制取消。 +一次选择的多个技能共享同一根。 + +Zero remains unlimited, including an explicit context override. Checks are +cooperative after tools and keep existing finalization. + +A paused child returns +its existing typed error; subsequent selected skills are not started after +that error. A later child may still perform its first tool round on an already +spent root. This is not request admission or a hard reservation. + +零额度仍不限,包括显式 context 覆盖。在工具后协作检查并保留已有收尾。 +暂停的 child 返回已有类型化错误,之后的选中技能不再启动;后续 child 仍可能 +在已耗尽的根上执行首个工具轮。这不是请求准入或硬预留。 + +The clock starts at preparation, not boot or earlier queueing. Local Agent +budgets remain stricter independent gates. Money, durable recovery, all +auxiliary consumers and physical process quiescence remain separate work. + +时钟从准备根开始,不含 boot 或更早排队。Agent 本地额度仍是独立的更严格 gate。 +金额、持久恢复、全部辅助消费者及物理进程静默仍是独立工作。 + +## Evidence / 证据 + +Boot HTTP/SSE tests exercise profile, preview, slash, composer multi-selection +and Goal against real file reads. Zero configuration and explicit zero remain +controls. + +Real Controller/Agent sequences cover root lifetime, old job +projection and cancellation/admission. These are constructed local tests, not +an observed production incident, frontend interaction or restart proof. + +Boot HTTP/SSE 测试通过真实文件读取覆盖 profile、preview、slash、composer +多选和 Goal,保留零配置与显式零值对照。真实 Controller/Agent 序列覆盖根寿命、 +旧 job 投射及取消/准入。这些是本机构造测试,不是线上事故、前端交互或重启证明。 + +No new API, configuration, wire or persisted schema is introduced. Model +selection, tool permissions and request construction stay with their owners. +The HTTP fixture checks stable child prefix/schema bytes within a run; it does +not measure live provider cache hit rate. The complete #14025 plan remains open. + +没有新增 API、配置、传输或持久化 schema。模型选择、工具权限和请求构造保留 +原有 owner。HTTP 夹具只核对同轮 child 前缀/schema 字节稳定,不测线上缓存命中率。 +完整 #14025 计划仍未闭环。 diff --git a/internal/assembly/boot/effect_skill_root_budget_test.go b/internal/assembly/boot/effect_skill_root_budget_test.go new file mode 100644 index 0000000000..51ef252450 --- /dev/null +++ b/internal/assembly/boot/effect_skill_root_budget_test.go @@ -0,0 +1,183 @@ +package boot + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "reasonix/internal/contract/event" + "reasonix/internal/runtime/agent" + "reasonix/internal/session/control" +) + +func TestEffectExplicitSkillRootBudget(t *testing.T) { + for _, tc := range []struct { + name string + limit int + wantReads int32 + }{ + {"profile", 1500, 2}, + {"preview", 1500, 2}, + {"slash", 1500, 2}, + {"chips", 3000, 2}, + {"goal", 0, 2}, + {"context_zero", 1500, 3}, + {"unconfigured", 0, 3}, + } { + t.Run(tc.name, func(t *testing.T) { + home, root := statedBootHome(t), t.TempDir() + writeFile(t, root, "fixture.txt", "explicit skill budget fixture") + for _, name := range []string{"probe", "second"} { + writeFile(t, home, "skills/"+name+"/SKILL.md", "---\nname: "+name+"\ndescription: Read the fixture\nrunAs: subagent\nread-only: true\nallowed-tools: read_file\nmodel: "+name+"\n---\nRead fixture.txt and report it.\n") + } + var calls [2]atomic.Int32 + var reads, receipts atomic.Int32 + var observed sync.Map + var prefixes sync.Map + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Model string + Tools []json.RawMessage + Messages []struct { + Role string + Content json.RawMessage + ToolCallID string `json:"tool_call_id"` + } + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + for _, message := range req.Messages { + if message.Role == "tool" && strings.Contains(string(message.Content), "explicit skill budget fixture") { + if _, loaded := observed.LoadOrStore(message.ToolCallID, true); !loaded { + reads.Add(1) + } + } + } + if len(req.Tools) > 0 && len(req.Messages) > 0 { + stable, _ := json.Marshal(struct { + Prefix json.RawMessage + Tools []json.RawMessage + }{req.Messages[0].Content, req.Tools}) + previous, loaded := prefixes.LoadOrStore(req.Model, stable) + if loaded && !bytes.Equal(previous.([]byte), stable) { + t.Error("child prefix/schema changed") + } + } + i := 0 + if req.Model == "second" { + i = 1 + } + n := calls[i].Add(1) + w.Header().Set("Content-Type", "text/event-stream") + maxReads := int32(3) + if tc.name == "chips" && i == 0 { + maxReads = 1 + } + if len(req.Tools) > 0 && n <= maxReads { + args, _ := json.Marshal(map[string]string{"path": "fixture.txt"}) + payload, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": fmt.Sprintf("read-%d-%d", i, n), "type": "function", "function": map[string]any{"name": "read_file", "arguments": string(args)}}}}, "finish_reason": "tool_calls"}}}) + _, _ = fmt.Fprintf(w, "data: %s\n\n", payload) + } else { + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"Fixture read complete.\"},\"finish_reason\":\"stop\"}]}\n\n") + } + _, _ = io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":100,\"total_tokens\":1100}}\n\ndata: [DONE]\n\n") + })) + t.Cleanup(srv.Close) + goalLimit := 0 + if tc.name == "goal" { + goalLimit = 1500 + } + writeFile(t, root, "reasonix.toml", fmt.Sprintf(`default_model = "probe" +[agent] +task_token_budget = %d +goal_token_budget = %d +[codegraph] +enabled = false +[[providers]] +name = "probe" +kind = "openai" +model = "probe" +base_url = %q +api_key_env = "SKILL_ROOT_EFFECT_KEY" +[[providers]] +name = "second" +kind = "openai" +model = "second" +base_url = %q +api_key_env = "SKILL_ROOT_EFFECT_KEY" +`, tc.limit, goalLimit, srv.URL, srv.URL)) + t.Setenv("SKILL_ROOT_EFFECT_KEY", "test") + approveWorkspaceAt(t, home, root) + done := make(chan struct{}, 1) + ctrl, err := Build(t.Context(), Options{Home: home, WorkspaceRoot: root, Sink: event.FuncSink(func(e event.Event) { + if e.Kind == event.Usage && e.UsageSource == event.UsageSourceSubagent { + receipts.Add(1) + if e.Usage == nil || e.Usage.RequestCount != 1 || e.Usage.Estimated { + t.Errorf("usage=%+v", e.Usage) + } + } + if e.Kind == event.TurnDone { + select { + case done <- struct{}{}: + default: + } + } + })}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(ctrl.Close) + ctrl.EnsureSessionPath() + ctrl.SetToolApprovalMode(control.ToolApprovalYolo) + ctx := t.Context() + if tc.name == "context_zero" { + ctx = agent.WithTaskBudget(ctx, agent.TaskBudget{}) + } + switch tc.name { + case "slash": + ctrl.Submit("/probe read the fixture") + case "goal": + ctrl.SetGoal("Read the fixture") + ctrl.Submit("/probe read the fixture") + case "chips": + ctrl.SubmitInvocationDisplay("/probe /second read the fixture", "read the fixture", []control.InvocationRequest{{Name: "probe", Kind: "subagent"}, {Name: "second", Kind: "subagent"}}) + default: + _, err = ctrl.RunSubagentProfile(ctx, "probe", "read the fixture", tc.name == "preview") + if tc.limit > 0 && tc.name != "context_zero" { + if pause, ok := agent.InspectRunPause(err); !ok || pause.Key != "token" { + t.Fatalf("pause=%+v err=%v", pause, err) + } + } else if err != nil { + t.Fatal(err) + } + } + if tc.name == "slash" || tc.name == "chips" || tc.name == "goal" { + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("skill turn did not finish") + } + } + if tc.name == "goal" && ctrl.GoalStatus() != control.GoalStatusBlocked { + t.Errorf("goal status=%s", ctrl.GoalStatus()) + } + if reads.Load() != tc.wantReads { + t.Errorf("real reads=%d, want %d; HTTP=%d/%d", reads.Load(), tc.wantReads, calls[0].Load(), calls[1].Load()) + } + if (tc.name == "slash" || tc.name == "chips") && receipts.Load() != calls[0].Load()+calls[1].Load() { + t.Errorf("receipts=%d HTTP=%d/%d", receipts.Load(), calls[0].Load(), calls[1].Load()) + } + }) + } +} diff --git a/internal/session/control/skill_root_budget_test.go b/internal/session/control/skill_root_budget_test.go new file mode 100644 index 0000000000..1baeab3939 --- /dev/null +++ b/internal/session/control/skill_root_budget_test.go @@ -0,0 +1,139 @@ +package control + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "reasonix/internal/contract/event" + "reasonix/internal/contract/provider" + "reasonix/internal/contract/tool" + "reasonix/internal/ext/skill" + "reasonix/internal/runtime/agent" + "reasonix/internal/runtime/agent/testutil" + "reasonix/internal/state/sessionstore" +) + +func TestProfileRootBudgetLifetime(t *testing.T) { + for _, mode := range []string{"new_task", "same_scope", "new_scope", "reset", "new_session", "inherited", "context_zero"} { + t.Run(mode, func(t *testing.T) { + parent := agent.New(nil, tool.NewRegistry(), sessionstore.NewSession("parent"), agent.Options{TaskBudget: agent.TaskBudget{Tokens: 1500}}, event.Discard) + var reads int + var oldContext context.Context + runner := func(ctx context.Context, _ skill.Skill, task string, _ skill.SubagentRunOptions) (string, error) { + oldContext = ctx + reg := tool.NewRegistry() + reg.Add(fakeControlTool{name: "read_file"}) + var turns []testutil.Turn + for i := range 3 { + turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{ID: fmt.Sprintf("read-%d", i), Name: "read_file", Arguments: `{}`}}, Usage: &provider.Usage{PromptTokens: 1000, CompletionTokens: 100, RequestCount: 1}}) + } + turns = append(turns, testutil.Turn{Text: "done"}) + child := agent.New(testutil.NewMock("child", turns...), reg, sessionstore.NewSession("child"), agent.Options{}, event.FuncSink(func(e event.Event) { + if e.Kind == event.ToolResult && e.Tool.Name == "read_file" && e.Tool.Err == "" { + reads++ + } + })) + err := child.Run(ctx, task) + if _, paused := agent.InspectRunPause(err); paused { + err = nil + } + return "done", err + } + dir := t.TempDir() + c := New(Options{Runner: parent, Executor: parent, SkillRunner: runner, SessionDir: dir, SessionPath: filepath.Join(dir, "s.jsonl"), Skills: []skill.Skill{{Name: "probe", RunAs: skill.RunSubagent}}}) + t.Cleanup(c.Close) + ctx := t.Context() + if mode != "new_task" && mode != "context_zero" { + ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: "one", TaskText: "read"}) + } + if mode == "inherited" { + owner := agent.New(nil, tool.NewRegistry(), sessionstore.NewSession("outer"), agent.Options{TaskBudget: agent.TaskBudget{Tokens: 1500}}, event.Discard) + ctx = owner.PrepareRootTaskBudget(ctx) + } + if mode == "context_zero" { + ctx = agent.WithTaskBudget(ctx, agent.TaskBudget{}) + } + if _, err := c.RunSubagentProfile(ctx, "probe", "read", false); err != nil { + t.Fatal(err) + } + first := reads + wantFirst, wantSecond := 2, 2 + if mode == "same_scope" || mode == "inherited" { + wantSecond = 1 + } + switch mode { + case "new_scope": + ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: "two", TaskText: "next"}) + case "reset": + parent.ResetTaskBudget() + case "new_session": + parent.SetSession(sessionstore.NewSession("new")) + case "context_zero": + wantFirst, wantSecond = 3, 3 + } + old := oldContext + if _, err := c.RunSubagentProfile(ctx, "probe", "next", false); err != nil { + t.Fatal(err) + } + if first != wantFirst || reads-first != wantSecond { + t.Fatalf("reads=%d/%d want=%d/%d", first, reads-first, wantFirst, wantSecond) + } + if mode == "reset" { + // A projected managed job has independent cancellation but retains its old spend. + jobCtx := agent.InheritRootTokenBudget(t.Context(), old) + before := reads + if _, err := runner(jobCtx, skill.Skill{}, "old job", skill.SubagentRunOptions{}); err != nil { + t.Fatal(err) + } + if reads-before != 1 { + t.Fatalf("old job reads=%d", reads-before) + } + } + }) + } +} + +func TestProfileBudgetPreparationUsesTurnAdmission(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + var calls atomic.Int32 + parent := agent.New(nil, tool.NewRegistry(), sessionstore.NewSession("parent"), agent.Options{}, event.Discard) + c := New(Options{Runner: parent, Executor: parent, Skills: []skill.Skill{{Name: "probe", RunAs: skill.RunSubagent}}, SkillRunner: func(ctx context.Context, _ skill.Skill, _ string, _ skill.SubagentRunOptions) (string, error) { + if calls.Add(1) != 1 { + return "unexpected concurrent run", nil + } + close(entered) + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-release: + return "released", nil + } + }}) + t.Cleanup(c.Close) + defer close(release) + done := make(chan error, 1) + go func() { _, err := c.RunSubagentProfile(t.Context(), "probe", "first", false); done <- err }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("runner did not enter") + } + _, second := c.RunSubagentProfile(t.Context(), "probe", "second", false) + if !errors.Is(second, ErrTurnRunning) || calls.Load() != 1 { + t.Errorf("second=%v calls=%d", second, calls.Load()) + } + c.Cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Errorf("cancel=%v", err) + } + case <-time.After(5 * time.Second): + t.Error("controller cancellation did not reach profile") + } +} diff --git a/internal/session/control/turn_entry.go b/internal/session/control/turn_entry.go index fb48259edd..7237534a67 100644 --- a/internal/session/control/turn_entry.go +++ b/internal/session/control/turn_entry.go @@ -297,15 +297,21 @@ func (c *Controller) RunSubagentProfile(ctx context.Context, name, task string, return "", fmt.Errorf("subagent skill runner is unavailable for %q", name) } - c.maybeSessionStart(ctx) - parentSession := c.parentSessionID() - ctx = agent.WithParentSession(ctx, parentSession) - ctx = jobs.WithSession(ctx, parentSession) - ctx, turnImgs := c.withTurnImages(ctx, task) - ctx = langpref.WithResponseLanguagePreference(ctx, c.display.responseLanguage) - ctx = langpref.WithReasoningLanguagePreference(ctx, c.display.reasoningLanguage) - ctx = agent.WithSubagentDepth(ctx, 0) - answer, err := runner(ctx, sk, c.imageRoutingPrefix(turnImgs)+task, skill.SubagentRunOptions{HostInitiated: true}) + var answer string + err := c.runSynchronousTurn(ctx, nil, func(ctx context.Context) error { + c.maybeSessionStart(ctx) + parentSession := c.parentSessionID() + ctx = agent.WithParentSession(ctx, parentSession) + ctx = jobs.WithSession(ctx, parentSession) + ctx, turnImgs := c.withTurnImages(ctx, task) + ctx = langpref.WithResponseLanguagePreference(ctx, c.display.responseLanguage) + ctx = langpref.WithReasoningLanguagePreference(ctx, c.display.reasoningLanguage) + ctx = agent.WithSubagentDepth(ctx, 0) + ctx = c.prepareTurnRootBudget(ctx) + var err error + answer, err = runner(ctx, sk, c.imageRoutingPrefix(turnImgs)+task, skill.SubagentRunOptions{HostInitiated: true}) + return err + }) if err != nil { return "", err } diff --git a/internal/session/control/turn_orchestrator.go b/internal/session/control/turn_orchestrator.go index 0de397328a..7c5025d1d2 100644 --- a/internal/session/control/turn_orchestrator.go +++ b/internal/session/control/turn_orchestrator.go @@ -82,13 +82,8 @@ func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, sk expectedContinuationEpoch := o.c.goals.continuationToken() images := o.c.resolveTurnImages(raw) ctx = o.c.withVisionRouting(agent.WithSubagentImageCandidates(ctx, images.candidates)) - // The skill turn's model requests count against the active goal's token - // budget, so bind a recorder for the span even though the sub-agent cannot - // call update_goal itself. if scopeID, goal, ok := o.c.goals.deliveryScope(); ok { ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: scopeID, TaskText: goal}) - recorder := o.c.goals.newTurnRecorder(scopeID, o.c.goals.continuationToken()) - o.c.goalUsageTee.setActiveRecorder(recorder) } startMessages := o.c.sessionMessageCount() err := o.runSubagentSkillTurns(ctx, skills, task, raw, display, runner, planMode, images) @@ -151,6 +146,8 @@ func (o *turnOrchestrator) runSubagentSkillTurns(ctx context.Context, skills []s } ctx, marker = c.beginTurn(ctx, startMessages, true) ctx = c.announceAuthoredTurn(ctx, firstNonEmpty(raw, task), startMessages) + ctx = c.bindTurnScope(ctx, nil) + ctx = c.prepareTurnRootBudget(ctx) c.executor.LandAuthoredUserMessage(ctx, provider.Message{Role: provider.RoleUser, Content: input, Images: images.userImages, CreatedAt: time.Now().UnixMilli()}) for _, sk := range skills { diff --git a/release-notes/studio/unreleased/14025-explicit-skill-root-budget.md b/release-notes/studio/unreleased/14025-explicit-skill-root-budget.md new file mode 100644 index 0000000000..39e92b77ad --- /dev/null +++ b/release-notes/studio/unreleased/14025-explicit-skill-root-budget.md @@ -0,0 +1,17 @@ +显式子代理技能调用遵守已有任务根预算。 + +## 修复 + +- slash、composer 多选及 Goal 技能调用共享已有 token/wall 根预算,保留零额度不限和原有收尾。关联 #14025。 +- CLI profile run/preview 复用同步回合准入及取消;忙碌、关闭、draining 和工作区 authority 检查同样适用。关联 #14025。 +- 本次不提供硬预留、根金额汇总或完整隔离恢复保障。关联 #14025。 + +--- + + + +## Fixed + +- Explicit slash, composer and Goal subagent skills share the existing token/wall root, preserving unlimited zero and finalization. Refs #14025. +- CLI profile run/preview use synchronous turn admission and cancellation, including busy, closed, draining and workspace authority checks. Refs #14025. +- Hard reservations, root money accounting and complete isolation/recovery remain outside this change. Refs #14025.