From 46b0c908d15993402bf20c5dde917cc3f455bf12 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:04:39 +0900 Subject: [PATCH 1/9] CRW-852: carry a pair's autoCompactTokenLimit as thread config An execution-policy pair, and the legacy single model/reasoningEffort declaration that is the same pair written another way, may now carry an optional positive integer autoCompactTokenLimit. When the authorized pair holds it, settings.Contract.Config() sends config.model_auto_compact_token_limit, so create_thread, create_worktree_thread, send_message_to_thread's resume and the relay's managed start (which creates through bridge.CreateThread) all carry it. An IF DeepSeek child then compacts below the provider's real input ceiling instead of stopping without a receipt above about 664k tokens. A value that is not a positive integer is refused while the policy is read, through the existing PolicyError path: no new refusal name. A policy that declares no limit is unchanged, and the field is absent from the policy description, which is the authorization surface. The host has no field that reports the value back, so the resume observation never compares it: its silence withholds nothing and fails no receipt. The receipt records it under requested and unobservable, and never under verified. docs/role-execution-policy.md and the crw-run bridge reference state the field, the recommended 550000 and the probe behind it. --- docs/role-execution-policy.md | 44 ++++++ internal/bridge/autocompact_test.go | 129 +++++++++++++++++ internal/bridge/create.go | 2 +- internal/bridge/execution/autocompact_test.go | 105 ++++++++++++++ internal/bridge/execution/execution.go | 15 +- internal/bridge/execution/roles.go | 77 ++++++++-- internal/bridge/mutations.go | 2 +- internal/bridge/settings/autocompact_test.go | 62 ++++++++ internal/bridge/settings/observation.go | 11 ++ internal/bridge/settings/settings.go | 15 ++ internal/bridge/worktree.go | 4 +- internal/relay/adapter/autocompact_test.go | 133 ++++++++++++++++++ plugins/crw/.codex-plugin/plugin.json | 2 +- .../crw/skills/crw-run/references/bridge.md | 8 ++ 14 files changed, 587 insertions(+), 22 deletions(-) create mode 100644 internal/bridge/autocompact_test.go create mode 100644 internal/bridge/execution/autocompact_test.go create mode 100644 internal/bridge/settings/autocompact_test.go create mode 100644 internal/relay/adapter/autocompact_test.go diff --git a/docs/role-execution-policy.md b/docs/role-execution-policy.md index d15e086b07..dacb7102ef 100644 --- a/docs/role-execution-policy.md +++ b/docs/role-execution-policy.md @@ -136,6 +136,50 @@ role that lists more than one pair is described differently: withheld from a thread the host has not loaded, as `unverified_pair_for_unloaded_thread`, exactly as a supervisor's or an exception's pair is. +## A pair may cap auto-compaction + +A host can report a context window far larger than what the provider behind a model actually +accepts. `inferhub/deepseek-v4.1-flash` is the measured case: the host reports 950,000 tokens, and +the provider cuts every request above roughly 664,000 with `Provider stream error: upstream error`, +so the child stops without a receipt before Codex compacts on its own. A pair may therefore carry +`autoCompactTokenLimit`, a positive integer the bridge sends as `model_auto_compact_token_limit` in +the `thread/start` and `thread/resume` `config`, which makes the host compact below its own window: + + "child": {"model": "inferhub/deepseek-v4.1-flash", "reasoningEffort": "none", "autoCompactTokenLimit": 550000} + + "child": {"pairs": [ + {"model": "inferhub/deepseek-v4.1-flash", "reasoningEffort": "none", "autoCompactTokenLimit": 550000} + ]} + +The field is optional and belongs to the pair, so it goes on whichever spelling declares that pair: +the single `model`/`reasoningEffort` form, or one entry of `pairs`. A file that declares none +behaves exactly as before — no key is sent and the host's own threshold stands. A supervisor and an +exception name no pair, so neither can carry one: a `supervisor` entry that declares the field does +not load, and so does an entry that puts it beside `pairs` instead of inside one. + +A value that is not a positive integer is refused when the file is read, through the same +`execution_policy_unreadable` path as every other unusable policy value: a string, a boolean, a +fraction or an exponent, zero and a negative number all fail there. The limit is not part of what a +request is judged against, so it changes neither which pairs a role allows nor the description +`get_capabilities` reports — that description is the authorization surface, as it is for MCP +profiles. + +The host has no field that reports this value back, so the bridge never compares it. A creation or a +resume is not withheld, and its receipt does not fail, because the host said nothing about it. The +receipt records it the way it records any setting the host cannot confirm: under `requested`, with +the value that was sent, and under `unobservable`; `verified` never lists it. `verification` stays +`observed_at_creation` or `observed_at_resume` for the settings that were actually compared, and +`not_requested` when the limit was the only thing asked for. + +**The recommended value is 550000**, about 110,000 below the highest input observed to pass +(664,238 tokens, 2026-10-06) and about 114,000 below the first observed failure. The margin is there +because compaction is decided between samples, and one tool result can add a lot between two of +them. The evidence is the parent's probe of 2026-10-06: two ephemeral threads under an isolated +`codex app-server`, both reporting a 950,000 window, given the same ~15,000-token input over three +turns. The thread given `config.model_auto_compact_token_limit=4000` compacted twice, its context +falling from 30,696 to 15,478; the control thread never compacted and grew 30,756 → 40,588 → +40,593. Writing the value into a host's policy file is an operator step, like the rest of that file. + ## A role may carry MCP profiles A thread starts every MCP server config.toml defines and every installed plugin provides, whether or diff --git a/internal/bridge/autocompact_test.go b/internal/bridge/autocompact_test.go new file mode 100644 index 0000000000..ba1913d87c --- /dev/null +++ b/internal/bridge/autocompact_test.go @@ -0,0 +1,129 @@ +package bridge + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/appserver/fakehost" + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/settings" + "github.com/thisisjun786/codex-relay-workflow/internal/pyjson" +) + +// autoCompactPolicy is one role whose pair carries the limit this issue recommends. +const autoCompactPolicy = `{"roles":{"child":{"model":"explicit-model","reasoningEffort":"high","autoCompactTokenLimit":550000}}}` + +// autoCompactRequest returns the parameters of the first request that used method. +func autoCompactRequest(t *testing.T, host *fakehost.Server, method string) (map[string]any, string) { + t.Helper() + for _, request := range host.Requests() { + if request.Method != method { + continue + } + var params map[string]any + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + t.Fatal(err) + } + return params, string(request.Params) + } + t.Fatalf("no %s request", method) + return nil, "" +} + +// autoCompactConfig is the config object the request carried, which is where the limit travels. +func autoCompactConfig(t *testing.T, host *fakehost.Server, method string) map[string]any { + t.Helper() + params, _ := autoCompactRequest(t, host, method) + return pyjson.Map(params["config"]) +} + +// autoCompactReceiptSettings is the settings receipt of a mutation, whose unobservable list is where +// a transmitted-but-unreported setting is recorded. +func autoCompactReceiptSettings(t *testing.T, receipt map[string]any) map[string]any { + t.Helper() + section := pyjson.Map(receipt["settings"]) + if section == nil { + t.Fatalf("receipt has no settings section: %v", receipt) + } + return section +} + +// autoCompactListed is whether a receipt list names the field, whichever slice type it was built as. +func autoCompactListed(value any, field string) bool { + switch list := value.(type) { + case []string: + return slices.Contains(list, field) + case []any: + return slices.Contains(list, any(field)) + } + return false +} + +// A creation under a pair that declares the limit sends it in thread/start's config, as an integer, +// and records it as sent but not observable. +func TestCreateThreadSendsThePairsAutoCompactLimit(t *testing.T) { + b, host := policyBridge(t, autoCompactPolicy) + cwd := t.TempDir() + host.Respond("thread/start", startReply(cwd)) + host.Respond("turn/start", fakehost.Reply{Result: map[string]any{"turn": map[string]any{"id": "turn-1"}}}) + input := createInput(cwd, "auto-compact-create") + input.Role = "child" + receipt, err := b.CreateThread(context.Background(), input) + if err != nil || receipt["status"] != "accepted" { + t.Fatalf("receipt=%v err=%v", receipt, err) + } + if got := autoCompactConfig(t, host, "thread/start")[settings.AutoCompactTokenLimitKey]; got != float64(550000) { + t.Fatalf("thread/start config = %v", autoCompactConfig(t, host, "thread/start")) + } + if _, raw := autoCompactRequest(t, host, "thread/start"); !strings.Contains(raw, `"model_auto_compact_token_limit":550000`) { + t.Fatalf("the limit did not leave as an integer: %s", raw) + } + section := autoCompactReceiptSettings(t, receipt) + if !autoCompactListed(section["unobservable"], settings.AutoCompactTokenLimitKey) { + t.Fatalf("settings receipt = %v", section) + } +} + +// A resume under the same pair sends the limit again, and a host answer that says nothing about it +// neither withholds the message nor fails the receipt. +func TestSendMessageSendsThePairsAutoCompactLimitAndStillDelivers(t *testing.T) { + b, host := policyBridge(t, autoCompactPolicy) + cwd := t.TempDir() + host.Respond("thread/read", fakehost.Reply{Result: map[string]any{"thread": map[string]any{"status": map[string]any{"type": "idle"}}}}) + host.Respond("thread/resume", startReply(cwd)) + host.Respond("turn/start", fakehost.Reply{Result: map[string]any{"turn": map[string]any{"id": "turn-1"}}}) + receipt, err := b.SendMessageToThread(context.Background(), SendMessage{RequestID: "auto-compact-send", ThreadID: "thread-1", Message: "work", Role: "child", Expected: map[string]any{"model": "explicit-model", "reasoning_effort": "high"}}) + if err != nil || receipt["status"] != "accepted" || host.Count("turn/start") != 1 { + t.Fatalf("the host's silence about the limit withheld the message: receipt=%v err=%v", receipt, err) + } + if got := autoCompactConfig(t, host, "thread/resume")[settings.AutoCompactTokenLimitKey]; got != float64(550000) { + t.Fatalf("thread/resume config = %v", autoCompactConfig(t, host, "thread/resume")) + } + section := autoCompactReceiptSettings(t, receipt) + if !autoCompactListed(section["unobservable"], settings.AutoCompactTokenLimitKey) { + t.Fatalf("settings receipt = %v", section) + } +} + +// A policy that declares no limit sends no such key and reports none, so every host keeps exactly +// the behaviour it had. +func TestAPolicyWithoutTheLimitSendsNoAutoCompactKey(t *testing.T) { + b, host := policyBridge(t, `{"roles":{"child":{"model":"explicit-model","reasoningEffort":"high"}}}`) + cwd := t.TempDir() + host.Respond("thread/start", startReply(cwd)) + host.Respond("turn/start", fakehost.Reply{Result: map[string]any{"turn": map[string]any{"id": "turn-1"}}}) + input := createInput(cwd, "auto-compact-absent") + input.Role = "child" + receipt, err := b.CreateThread(context.Background(), input) + if err != nil || receipt["status"] != "accepted" { + t.Fatalf("receipt=%v err=%v", receipt, err) + } + if _, present := autoCompactConfig(t, host, "thread/start")[settings.AutoCompactTokenLimitKey]; present { + t.Fatalf("thread/start config = %v", autoCompactConfig(t, host, "thread/start")) + } + if autoCompactListed(autoCompactReceiptSettings(t, receipt)["unobservable"], settings.AutoCompactTokenLimitKey) { + t.Fatalf("settings receipt = %v", receipt["settings"]) + } +} diff --git a/internal/bridge/create.go b/internal/bridge/create.go index aca33e64d7..c18b8edbdb 100644 --- a/internal/bridge/create.go +++ b/internal/bridge/create.go @@ -101,7 +101,7 @@ func (b *Bridge) CreateThread(ctx context.Context, in CreateThread) (ledger.Rece if err != nil { return err } - contract = settings.Contract{CWD: cwd, Sandbox: in.Sandbox, Model: authorized.Model, ReasoningEffort: authorized.Effort, Roots: in.Roots, ExpectedPolicy: in.Policy} + contract = settings.Contract{CWD: cwd, Sandbox: in.Sandbox, Model: authorized.Model, ReasoningEffort: authorized.Effort, Roots: in.Roots, ExpectedPolicy: in.Policy, AutoCompactTokenLimit: authorized.AutoCompactTokenLimit} if err := contract.Validate(); err != nil { return err } diff --git a/internal/bridge/execution/autocompact_test.go b/internal/bridge/execution/autocompact_test.go new file mode 100644 index 0000000000..b6c2d27abc --- /dev/null +++ b/internal/bridge/execution/autocompact_test.go @@ -0,0 +1,105 @@ +package execution + +import ( + "reflect" + "strings" + "testing" +) + +// The limit lives on a pair, and the legacy single model/reasoningEffort declaration is a pair +// written another way, so both spellings read it. +func TestAutoCompactTokenLimitIsReadOnAPairAndOnTheLegacyDeclaration(t *testing.T) { + for _, scenario := range []struct { + name string + roles doc + }{ + {"legacy-single", doc{"child": doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": 550000}}}, + {"pairs", doc{"child": doc{"pairs": []any{doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": 550000}}}}}, + } { + t.Run(scenario.name, func(t *testing.T) { + p := mustLoad(t, rolesPolicy(scenario.roles, true)) + role, ok := p.Role("child") + if !ok || len(role.Pairs) != 1 || role.Pairs[0].AutoCompactTokenLimit == nil || *role.Pairs[0].AutoCompactTokenLimit != 550000 { + t.Fatalf("role=%+v ok=%v", role, ok) + } + if !role.Allows(model, effort) { + t.Fatal("the limit changed which pairs the role allows") + } + }) + } +} + +// A policy that declares no limit is unchanged: the pair carries nothing and the file reads as it +// always did. +func TestAutoCompactTokenLimitAbsentLeavesThePolicyAsItWas(t *testing.T) { + p := mustLoad(t, rolesPolicy(nil, true)) + role, ok := p.Role("child") + if !ok || len(role.Pairs) != 1 || role.Pairs[0].AutoCompactTokenLimit != nil { + t.Fatalf("role=%+v ok=%v", role, ok) + } +} + +// A value that is not a positive integer is refused while the file is read, through the policy error +// every other unusable value is refused with. No new refusal name appears. +func TestAutoCompactTokenLimitIsRefusedWhenItIsNotAPositiveInteger(t *testing.T) { + for _, value := range []string{`"550000"`, `0`, `-1`, `550000.0`, `1.5e6`, `true`, `null`} { + t.Run(value, func(t *testing.T) { + raw := []byte(`{"roles":{"child":{"model":"` + model + `","reasoningEffort":"` + effort + `","autoCompactTokenLimit":` + value + `}}}`) + _, err := FromBytes(raw, "test-policy") + refusal := policyError(t, err) + if !strings.Contains(refusal.Detail, "autoCompactTokenLimit") { + t.Fatalf("the refusal does not name the field: %q", refusal.Detail) + } + }) + } +} + +// A limit on the pairs list is refused on the same path, and a pairs entry is still exactly one +// pair of model and effort however the limit is written. +func TestAutoCompactTokenLimitOnAPairsEntryIsReadAndRefusedTheSameWay(t *testing.T) { + good := doc{"child": doc{"pairs": []any{doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": 1}}}} + p := mustLoad(t, rolesPolicy(good, true)) + role, _ := p.Role("child") + if role.Pairs[0].AutoCompactTokenLimit == nil || *role.Pairs[0].AutoCompactTokenLimit != 1 { + t.Fatalf("role=%+v", role) + } + bad := doc{"child": doc{"pairs": []any{doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": "550000"}}}} + _, err := load(t, rolesPolicy(bad, true)) + policyError(t, err) + // Two entries that name the same model and effort are still the same pair, limit or not. + twice := doc{"child": doc{"pairs": []any{ + doc{"model": model, "reasoningEffort": effort}, + doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": 550000}, + }}} + _, err = load(t, rolesPolicy(twice, true)) + if !strings.Contains(policyError(t, err).Detail, "twice") { + t.Fatalf("err=%v", err) + } +} + +// The limit is not part of what a request is judged against; it travels out of the authorization so +// the caller can send it, and it is absent for a supervisor or an exception, which name no pair. +func TestAuthorizeCarriesTheMatchedPairsLimit(t *testing.T) { + p := mustLoad(t, rolesPolicy(doc{"child": doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": 550000}}, true)) + auth := authorize(t, p, Input{Model: model, Effort: effort, Role: "child"}) + if auth.AutoCompactTokenLimit == nil || *auth.AutoCompactTokenLimit != 550000 { + t.Fatalf("authorized=%+v", auth) + } + if role, _ := p.Role("child"); role.Pairs[0].AutoCompactTokenLimit == nil { + t.Fatal("the limit was dropped from the role") + } + // A supervisor declares no pair, so there is nothing for a limit to belong to. + if _, err := load(t, rolesPolicy(doc{"supervisor": doc{"expectation": "record", "autoCompactTokenLimit": 550000}}, true)); err == nil { + t.Fatal("a supervisor was allowed to declare a limit") + } +} + +// The policy description is the authorization surface, not an inventory of how a pair runs. The +// limit is deliberately absent from it, so every existing description and refusal is unchanged. +func TestThePolicyDescriptionDoesNotMentionTheLimit(t *testing.T) { + withLimit := mustLoad(t, rolesPolicy(doc{"child": doc{"model": model, "reasoningEffort": effort, "autoCompactTokenLimit": 550000}}, true)) + without := mustLoad(t, rolesPolicy(doc{"child": doc{"model": model, "reasoningEffort": effort}}, true)) + if !reflect.DeepEqual(withLimit.Summary()["roles"], without.Summary()["roles"]) { + t.Fatalf("with=%v without=%v", withLimit.Summary()["roles"], without.Summary()["roles"]) + } +} diff --git a/internal/bridge/execution/execution.go b/internal/bridge/execution/execution.go index ae3f3c2b04..237bcd9cb5 100644 --- a/internal/bridge/execution/execution.go +++ b/internal/bridge/execution/execution.go @@ -85,6 +85,10 @@ type Authorized struct { // to another of them since, and a caller's stale statement would undo that. Pinned bool Receipt map[string]any + // AutoCompactTokenLimit is the matched pair's optional model_auto_compact_token_limit, carried + // out so a creation or resume can send it. It is nil when the pair declares none, and for an + // exception or a supervisor, neither of which names a pair for a limit to belong to. + AutoCompactTokenLimit *int64 } func (p Policy) Mode() string { @@ -161,10 +165,11 @@ func (p Policy) Authorize(in Input) (Authorized, error) { return Authorized{}, &Refusal{Code: RoleUnknown, Field: "role", Requested: in.Role, Allowed: anys(sortedKeys(p.roles)), Detail: "no such role is declared in this host's execution policy; this bridge declares no pair of its own for any role"} } if role.Expectation == Pair { - if !role.Allows(model, effort) { + pair, matchedDeclared := role.pairFor(model, effort) + if !matchedDeclared { return Authorized{}, role.mismatch(in.Role, model, effort) } - matched = &RolePair{Model: model, Effort: effort} + matched = &pair provenance = "role_pair" pinned = len(role.Pairs) == 1 } @@ -187,7 +192,11 @@ func (p Policy) Authorize(in Input) (Authorized, error) { expectation["overriddenBy"] = overriddenBy receipt["roleExpectation"] = expectation } - return Authorized{Model: model, Effort: effort, Provenance: provenance, Pinned: pinned, Receipt: receipt}, nil + var limit *int64 + if matched != nil { + limit = matched.AutoCompactTokenLimit + } + return Authorized{Model: model, Effort: effort, Provenance: provenance, Pinned: pinned, AutoCompactTokenLimit: limit, Receipt: receipt}, nil } func (p Policy) exceptionCovers(in Input, model, effort string) error { diff --git a/internal/bridge/execution/roles.go b/internal/bridge/execution/roles.go index f5313bd7be..df9d90be2f 100644 --- a/internal/bridge/execution/roles.go +++ b/internal/bridge/execution/roles.go @@ -1,8 +1,9 @@ package execution import ( + "encoding/json" "fmt" - "slices" + "strconv" "strings" ) @@ -23,7 +24,15 @@ const supportedRoles = `"child", "parent", "supervisor"` // RolePair is one model and reasoning effort a role may run on. A request matches a pair only when // both halves are equal: an effort name belongs to the model beside it, so two models that both // offer "xhigh" do not thereby offer the same thing. -type RolePair struct{ Model, Effort string } +// +// AutoCompactTokenLimit is the pair's optional model_auto_compact_token_limit. It travels in the +// thread/start and thread/resume config, and the host never reports it back, so it is a property of +// the pair's execution and not part of what a request is judged against: the pair's identity stays +// model and effort, and a nil limit leaves the host's own auto-compaction threshold in place. +type RolePair struct { + Model, Effort string + AutoCompactTokenLimit *int64 +} // Role is roles.RoleExpectation. Pairs is every pair the role may run on, in the order the policy // file declares them (the legacy model and reasoningEffort keys declare one); it is empty for a @@ -35,9 +44,22 @@ type Role struct { MCP *MCPProfiles } +// pairFor is the declared pair whose model and effort a request states. The limit is not part of the +// identity: two entries that name the same model and effort are the same pair, and the declaration's +// limit travels with whichever entry matched. +func (r Role) pairFor(model, effort string) (RolePair, bool) { + for _, pair := range r.Pairs { + if pair.Model == model && pair.Effort == effort { + return pair, true + } + } + return RolePair{}, false +} + // Allows is whether a request stating model and effort is one of the role's pairs. func (r Role) Allows(model, effort string) bool { - return slices.Contains(r.Pairs, RolePair{Model: model, Effort: effort}) + _, ok := r.pairFor(model, effort) + return ok } // pairValues is the pairs as the policy description and a refusal carry them. @@ -115,7 +137,7 @@ func parseRoles(declared any) (map[string]Role, error) { if !ok { return nil, &PolicyError{fmt.Sprintf("role %s must be an object", repr(name))} } - if err := only(entry, []string{"expectation", "mcp", "model", "pairs", "reasoningEffort"}, "role "+repr(name)); err != nil { + if err := only(entry, []string{"autoCompactTokenLimit", "expectation", "mcp", "model", "pairs", "reasoningEffort"}, "role "+repr(name)); err != nil { return nil, err } role, err := parseRole(name, entry) @@ -153,14 +175,14 @@ func parseRole(name string, entry object) (Role, error) { return Role{}, &PolicyError{fmt.Sprintf("role %s expectation must be 'pair': only 'supervisor' defers to the recorded authorization, and letting another role do so would exempt it from the role check", repr(name))} } if name == Supervisor { - if named := present(entry, "model", "pairs", "reasoningEffort"); len(named) > 0 { - return Role{}, &PolicyError{fmt.Sprintf("role 'supervisor' cannot declare %s: its model and effort are the user's own selection, so its expectation is the recorded authorization", repr(named))} + if named := present(entry, "autoCompactTokenLimit", "model", "pairs", "reasoningEffort"); len(named) > 0 { + return Role{}, &PolicyError{fmt.Sprintf("role 'supervisor' cannot declare %s: its model and effort are the user's own selection, so its expectation is the recorded authorization and it carries no pair for a limit to belong to", repr(named))} } return Role{Expectation: Record}, nil } if has(entry, "pairs") { - if named := present(entry, "model", "reasoningEffort"); len(named) > 0 { - return Role{}, &PolicyError{fmt.Sprintf("role %s declares both pairs and %s; state one pair as model and reasoningEffort, or several as pairs", repr(name), repr(named))} + if named := present(entry, "autoCompactTokenLimit", "model", "reasoningEffort"); len(named) > 0 { + return Role{}, &PolicyError{fmt.Sprintf("role %s declares both pairs and %s; state one pair as model, reasoningEffort and autoCompactTokenLimit, or several as pairs", repr(name), repr(named))} } pairs, err := parsePairs(name, entry.Get("pairs")) if err != nil { @@ -179,11 +201,17 @@ func parseRole(name string, entry object) (Role, error) { if err != nil { return Role{}, err } - return Role{Pairs: []RolePair{{Model: model, Effort: effort}}, Expectation: Pair}, nil + var limit *int64 + if has(entry, "autoCompactTokenLimit") { + if limit, err = autoCompactTokenLimit(entry.Get("autoCompactTokenLimit"), "role "+repr(name)); err != nil { + return Role{}, err + } + } + return Role{Pairs: []RolePair{{Model: model, Effort: effort, AutoCompactTokenLimit: limit}}, Expectation: Pair}, nil } // parsePairs reads the pairs list of a role: a non-empty list of {model, reasoningEffort} objects, -// none repeated. +// none repeated, each optionally carrying an autoCompactTokenLimit. func parsePairs(name string, declared any) ([]RolePair, error) { list, ok := declared.([]any) if !ok || len(list) == 0 { @@ -196,7 +224,7 @@ func parsePairs(name string, declared any) ([]RolePair, error) { if !ok { return nil, &PolicyError{where + " must be an object"} } - if err := only(entry, []string{"model", "reasoningEffort"}, where); err != nil { + if err := only(entry, []string{"autoCompactTokenLimit", "model", "reasoningEffort"}, where); err != nil { return nil, err } if absent := absent(entry, "model", "reasoningEffort"); len(absent) > 0 { @@ -210,11 +238,32 @@ func parsePairs(name string, declared any) ([]RolePair, error) { if err != nil { return nil, err } - pair := RolePair{Model: model, Effort: effort} - if slices.Contains(pairs, pair) { + var limit *int64 + if has(entry, "autoCompactTokenLimit") { + if limit, err = autoCompactTokenLimit(entry.Get("autoCompactTokenLimit"), where); err != nil { + return nil, err + } + } + if _, repeated := (Role{Pairs: pairs}).pairFor(model, effort); repeated { return nil, &PolicyError{fmt.Sprintf("role %s lists the pair %s at %s twice", repr(name), repr(model), repr(effort))} } - pairs = append(pairs, pair) + pairs = append(pairs, RolePair{Model: model, Effort: effort, AutoCompactTokenLimit: limit}) } return pairs, nil } + +// autoCompactTokenLimit reads a pair's optional model_auto_compact_token_limit. It must be a +// positive integer: a string, a boolean, a fraction or an exponent, zero and a negative value are +// all refused while the policy is read, through the same PolicyError every other unusable policy +// value is refused with. The field is new, so nothing it refuses used to load. +func autoCompactTokenLimit(value any, where string) (*int64, error) { + spelled, ok := value.(json.Number) + if !ok || strings.ContainsAny(string(spelled), ".eE") { + return nil, &PolicyError{where + " must state autoCompactTokenLimit as a positive integer"} + } + parsed, err := strconv.ParseInt(string(spelled), 10, 64) + if err != nil || parsed <= 0 { + return nil, &PolicyError{where + " must state autoCompactTokenLimit as a positive integer"} + } + return &parsed, nil +} diff --git a/internal/bridge/mutations.go b/internal/bridge/mutations.go index 03b1cdd7d8..7e545aad04 100644 --- a/internal/bridge/mutations.go +++ b/internal/bridge/mutations.go @@ -76,7 +76,7 @@ func (b *Bridge) SendMessageToThread(ctx context.Context, in SendMessage) (ledge if err != nil { return err } - contract = settings.Contract{CWD: pyjson.Text(expected["cwd"]), Sandbox: pyjson.Text(expected["sandbox"]), ExpectedPolicy: pyjson.Map(expected["expected_sandbox_policy"]), Model: auth.Model, ReasoningEffort: auth.Effort} + contract = settings.Contract{CWD: pyjson.Text(expected["cwd"]), Sandbox: pyjson.Text(expected["sandbox"]), ExpectedPolicy: pyjson.Map(expected["expected_sandbox_policy"]), Model: auth.Model, ReasoningEffort: auth.Effort, AutoCompactTokenLimit: auth.AutoCompactTokenLimit} if roots, ok := expected["runtime_workspace_roots"].([]any); ok { for _, root := range roots { contract.Roots = append(contract.Roots, pyjson.Text(root)) diff --git a/internal/bridge/settings/autocompact_test.go b/internal/bridge/settings/autocompact_test.go new file mode 100644 index 0000000000..e6d6ff17eb --- /dev/null +++ b/internal/bridge/settings/autocompact_test.go @@ -0,0 +1,62 @@ +package settings + +import ( + "slices" + "testing" +) + +// The config object is what thread/start and thread/resume carry, so the limit travels on both. A +// contract that holds no limit sends no key at all, which is today's behaviour. +func TestConfigAndResumeParamsCarryTheAutoCompactLimitOnlyWhenItIsSet(t *testing.T) { + limit := int64(550000) + set := Contract{CWD: "/w", Model: "m", ReasoningEffort: "high", AutoCompactTokenLimit: &limit} + if got := set.Config()[AutoCompactTokenLimitKey]; got != int64(550000) { + t.Fatalf("Config() = %v", set.Config()) + } + for _, params := range []map[string]any{set.StartParams(), set.ResumeParams("thread-1")} { + if got := params["config"].(map[string]any)[AutoCompactTokenLimitKey]; got != int64(550000) { + t.Fatalf("params = %v", params) + } + } + without := Contract{CWD: "/w", Model: "m", ReasoningEffort: "high"} + if _, present := without.Config()[AutoCompactTokenLimitKey]; present { + t.Fatalf("Config() = %v", without.Config()) + } + if _, present := without.ResumeParams("thread-1")["config"].(map[string]any)[AutoCompactTokenLimitKey]; present { + t.Fatalf("ResumeParams = %v", without.ResumeParams("thread-1")) + } +} + +// The host has no field to report this in, so its silence is not a mismatch and never withholds a +// message; the receipt records the value as requested and unobservable, and never as verified. +func TestTheReceiptRecordsTheLimitAsUnobservableAndNeverWithholds(t *testing.T) { + limit := int64(550000) + c := Contract{Model: "m", ReasoningEffort: "high", AutoCompactTokenLimit: &limit} + response := map[string]any{"approvalPolicy": "never", "model": "m", "reasoningEffort": "high"} + if findings := c.Findings(response); len(findings) != 0 { + t.Fatalf("the host's silence about the limit was read as a mismatch: %v", findings) + } + receipt := c.Receipt(response, "resume") + if receipt["verification"] != "observed_at_resume" { + t.Fatalf("verification = %v", receipt["verification"]) + } + if !slices.Contains(receipt["unobservable"].([]string), AutoCompactTokenLimitKey) { + t.Fatalf("unobservable = %v", receipt["unobservable"]) + } + if slices.Contains(receipt["verified"].([]string), AutoCompactTokenLimitKey) { + t.Fatalf("verified = %v", receipt["verified"]) + } + if got := receipt["requested"].(map[string]any)[AutoCompactTokenLimitKey]; got != int64(550000) { + t.Fatalf("requested = %v", receipt["requested"]) + } +} + +// A contract whose only setting is the limit has observed nothing, so its receipt says so rather +// than claiming a verification the host never gave. +func TestALimitAloneIsNotAVerification(t *testing.T) { + limit := int64(1) + receipt := (&Contract{AutoCompactTokenLimit: &limit}).Receipt(map[string]any{"approvalPolicy": "never"}, "resume") + if receipt["verification"] != "not_requested" || len(receipt["verified"].([]string)) != 0 { + t.Fatalf("receipt = %v", receipt) + } +} diff --git a/internal/bridge/settings/observation.go b/internal/bridge/settings/observation.go index 1ce5a1231c..a4c8592ad3 100644 --- a/internal/bridge/settings/observation.go +++ b/internal/bridge/settings/observation.go @@ -84,15 +84,26 @@ func (c Contract) Receipt(response map[string]any, at string) map[string]any { } else if len(asked) > 0 { verification = "observed_at_" + at for k := range asked { + // Asking the host for this one is not observing it: the protocol has no field for it, so + // a receipt that listed it as verified would claim a confirmation nothing can give. + if k == AutoCompactTokenLimitKey { + continue + } verified = append(verified, k) } sort.Strings(verified) + if len(verified) == 0 { + verification = "not_requested" + } } for _, f := range findings { if f.Code == Unobservable { unobservable = append(unobservable, f.Field) } } + if c.AutoCompactTokenLimit != nil { + unobservable = append(unobservable, AutoCompactTokenLimitKey) + } sort.Strings(unobservable) return map[string]any{"requested": asked, "actual": Observed(response), "verified": verified, "unobservable": unobservable, "findings": findings, "verification": verification, "observationLimits": observationLimits} } diff --git a/internal/bridge/settings/settings.go b/internal/bridge/settings/settings.go index 5f02eb2ec6..20827e447b 100644 --- a/internal/bridge/settings/settings.go +++ b/internal/bridge/settings/settings.go @@ -18,6 +18,11 @@ const ( UnsupportedSandbox = "unsupported_sandbox_type" ) +// AutoCompactTokenLimitKey is the config key that carries a pair's optional auto-compaction +// threshold. It travels in the thread/start and thread/resume config; the host never reports it +// back, so nothing compares it and its silence withholds nothing. +const AutoCompactTokenLimitKey = "model_auto_compact_token_limit" + var ErrInvalid = errors.New("invalid settings") // invalid is a declaration the settings contract refuses; it matches ErrInvalid. @@ -51,6 +56,10 @@ type Contract struct { ExpectedPolicy map[string]any // MCP is what the role's MCP profile resolved to; nil when the thread has no profile. MCP *MCPExpectation + // AutoCompactTokenLimit is the authorized pair's optional model_auto_compact_token_limit, sent + // in the config so the host compacts below its own (larger) window. The host never reports it + // back, so it is recorded as unobservable rather than compared. Nil leaves the host's default. + AutoCompactTokenLimit *int64 } var defaults = map[string]map[string]any{ @@ -183,6 +192,9 @@ func (c Contract) Requested() map[string]any { if c.MCP != nil { asked["mcpServers"] = c.MCP.Shape() } + if c.AutoCompactTokenLimit != nil { + asked[AutoCompactTokenLimitKey] = *c.AutoCompactTokenLimit + } return asked } @@ -209,6 +221,9 @@ func (c Contract) Config() map[string]any { config[key] = section } } + if c.AutoCompactTokenLimit != nil { + config[AutoCompactTokenLimitKey] = *c.AutoCompactTokenLimit + } return config } diff --git a/internal/bridge/worktree.go b/internal/bridge/worktree.go index c631979804..74e5378e4d 100644 --- a/internal/bridge/worktree.go +++ b/internal/bridge/worktree.go @@ -72,7 +72,7 @@ func (b *Bridge) CreateWorktreeThread(ctx context.Context, in CreateWorktree) (l if err != nil { return err } - contract = settings.Contract{Sandbox: in.Sandbox, ExpectedPolicy: in.Policy, Model: auth.Model, ReasoningEffort: auth.Effort} + contract = settings.Contract{Sandbox: in.Sandbox, ExpectedPolicy: in.Policy, Model: auth.Model, ReasoningEffort: auth.Effort, AutoCompactTokenLimit: auth.AutoCompactTokenLimit} return contract.Validate() } result, err := b.mutate(ctx, mutation{in.RequestID, "create_worktree_thread", params, validate, func(ctx context.Context, receipt ledger.Receipt, effects *[]string) error { @@ -165,7 +165,7 @@ func (b *Bridge) CreateWorktreeThread(ctx context.Context, in CreateWorktree) (l if watch, err = b.watchSubscription(ctx, threadID, true); err != nil { return err } - placed := settings.Contract{CWD: w.Destination, Sandbox: in.Sandbox, ExpectedPolicy: in.Policy, Model: auth.Model, ReasoningEffort: auth.Effort, Roots: []string{w.Destination}} + placed := settings.Contract{CWD: w.Destination, Sandbox: in.Sandbox, ExpectedPolicy: in.Policy, Model: auth.Model, ReasoningEffort: auth.Effort, Roots: []string{w.Destination}, AutoCompactTokenLimit: auth.AutoCompactTokenLimit} receipt["threadId"] = threadID receipt["permissionReceipt"] = map[string]any{"approvalPolicy": created["approvalPolicy"], "sandbox": created["sandbox"], "activePermissionProfile": created["activePermissionProfile"], "runtimeWorkspaceRoots": created["runtimeWorkspaceRoots"]} receipt["desktopProjectAssociation"] = map[string]any{"status": "unverified", "sourceRepository": in.Source, "checkout": created["cwd"], "appServerProjectId": pyjson.Map(created["thread"])["projectId"]} diff --git a/internal/relay/adapter/autocompact_test.go b/internal/relay/adapter/autocompact_test.go new file mode 100644 index 0000000000..3a01179fe7 --- /dev/null +++ b/internal/relay/adapter/autocompact_test.go @@ -0,0 +1,133 @@ +package adapter + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/appserver/fakehost" + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/settings" + "github.com/thisisjun786/codex-relay-workflow/internal/pyjson" + "github.com/thisisjun786/codex-relay-workflow/internal/relay/delivery" + "github.com/thisisjun786/codex-relay-workflow/internal/relay/managed" + "github.com/thisisjun786/codex-relay-workflow/internal/relay/registry" +) + +// autoCompactManagedPolicy is the child pair a managed start creates under, carrying the limit this +// issue recommends. Managed start reaches the bridge's settings contract, so the value the operator +// puts on the pair is what the creation sends. +const autoCompactManagedPolicy = `{"roles":{"parent":{"model":"gpt-5.4","reasoningEffort":"medium"},"child":{"model":"gpt-5.4","reasoningEffort":"medium","autoCompactTokenLimit":550000}}}` + +// autoCompactHostParams returns the parameters of the first request that used method. +func autoCompactHostParams(t *testing.T, host *fakehost.Server, method string) map[string]any { + t.Helper() + for _, request := range host.Requests() { + if request.Method != method { + continue + } + var params map[string]any + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + t.Fatal(err) + } + return params + } + t.Fatalf("no %s request", method) + return nil +} + +// A managed start whose child pair declares the limit carries it in the creation's thread/start +// config, which is the path an IF DeepSeek child is created through. +func TestManagedStartSendsTheChildPairsAutoCompactLimit(t *testing.T) { + root := t.TempDir() + state := filepath.Join(root, "state") + t.Setenv("CODEX_SESSION_RELAY_SCOPE_DIR", filepath.Join(root, "scopes")) + workspace := filepath.Join(root, "work") + if err := os.Mkdir(workspace, 0700); err != nil { + t.Fatal(err) + } + policyPath := filepath.Join(root, "policy.json") + if err := os.WriteFile(policyPath, []byte(autoCompactManagedPolicy), 0600); err != nil { + t.Fatal(err) + } + t.Setenv(execution.EnvPolicy, policyPath) + registry.ResetRolePolicySnapshot() + t.Cleanup(registry.ResetRolePolicySnapshot) + policy, err := execution.FromBytes([]byte(autoCompactManagedPolicy), policyPath) + if err != nil { + t.Fatal(err) + } + declared := func() map[string]any { + return map[string]any{"sandbox": map[string]any{"type": "readOnly", "networkAccess": false}, "approvalPolicy": "never", "cwd": workspace, "runtimeWorkspaceRoots": []any{workspace}, "model": "gpt-5.4", "reasoningEffort": "medium", "environments": []any{map[string]any{"environmentId": "local", "cwd": workspace, "runtimeWorkspaceRoots": []any{workspace}}}} + } + request := map[string]any{"schema": managed.Schema, "requestId": "managed-auto-compact", "issueKey": "CRW-852", "parent": map[string]any{"taskId": "parent", "hostId": "host", "settings": declared()}, "child": map[string]any{"hostId": "host", "title": "Verify", "settings": declared()}, "artifactRoots": []any{workspace}, "allowedRecipients": []any{"parent"}, "criteria": []any{map[string]any{"id": "c1", "title": "preserve replay identity", "required": true}}, "criteriaSource": "issue:CRW-852", "baselineRevision": "baseline", "scopeRef": "issue:CRW-852", "prompt": "business-secret"} + host := fakehost.Start(t) + var mu sync.Mutex + turns := 0 + response := func() map[string]any { + answer := map[string]any{} + for key, value := range declared() { + if key != "environments" { + answer[key] = value + } + } + answer["thread"] = map[string]any{"id": "managed-child", "environments": declared()["environments"]} + return answer + } + host.Respond("thread/start", fakehost.Reply{Result: response()}) + host.Respond("thread/name/set", fakehost.Reply{Result: map[string]any{}}) + host.Handle("turn/start", func(json.RawMessage) fakehost.Reply { + mu.Lock() + defer mu.Unlock() + turns++ + id := "standby" + if turns > 1 { + id = "business" + } + return fakehost.Reply{Result: map[string]any{"turn": map[string]any{"id": id}}} + }) + host.Respond("thread/read", fakehost.Reply{Result: map[string]any{"thread": map[string]any{"status": map[string]any{"type": "idle"}, "canAcceptDirectInput": true, "model": "gpt-5.4", "reasoningEffort": "medium", "cwd": workspace}}}) + host.Respond("thread/resume", fakehost.Reply{Result: response()}) + host.Respond("thread/goal/get", fakehost.Reply{Result: map[string]any{"goal": nil}}) + host.Respond("thread/turns/list", fakehost.Reply{Result: map[string]any{"data": []any{map[string]any{"id": "standby", "status": "completed"}}, "nextCursor": nil}}) + host.Handle("thread/list", func(raw json.RawMessage) fakehost.Reply { + var params map[string]any + if err := json.Unmarshal(raw, ¶ms); err != nil { + t.Error(err) + } + data := []any{} + if params["archived"] != true { + data = append(data, map[string]any{"id": "managed-child"}) + } + return fakehost.Reply{Result: map[string]any{"data": data, "nextCursor": nil}} + }) + store, err := openStore(context.Background(), filepath.Join(state, "relay.sqlite3"), host.SocketPath) + if err != nil { + t.Fatal(err) + } + a, err := Open(host.SocketPath, state, Options{Policy: policy, Clock: delivery.NewFakeClock()}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { a.Close(); store.Close() }) + raw, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + engine := managed.Start{Store: store, Adapter: Managed{a}, Now: delivery.NewFakeClock().ISO, Socket: host.SocketPath, MarkerRoot: filepath.Join(root, "markers"), StateSelector: state, Readiness: func(context.Context, map[string]any) (string, error) { return "", nil }} + result, err := engine.Run(context.Background(), raw) + if err != nil { + t.Fatal(err) + } + if result.Get("state") != "admitted" { + business, _ := a.GetOperation(context.Background(), pyjson.Text(result.Get("businessRequestId"))) + t.Fatalf("managed start did not admit: %s business=%v", dumps(result, false), business) + } + config, _ := autoCompactHostParams(t, host, "thread/start")["config"].(map[string]any) + if config == nil || config[settings.AutoCompactTokenLimitKey] != float64(550000) { + t.Fatalf("thread/start config = %v", config) + } +} diff --git a/plugins/crw/.codex-plugin/plugin.json b/plugins/crw/.codex-plugin/plugin.json index 7630c00b3a..3ef6608e14 100644 --- a/plugins/crw/.codex-plugin/plugin.json +++ b/plugins/crw/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "crw", - "version": "0.4.0+ec3279cbbceb", + "version": "0.4.0+d7eef67ba94b", "description": "Linear-driven workflow skills for Codex: define an initiative, plan projects and issues, run delivery, and check the result against its canonical criteria.", "author": { "name": "thisisjun786", diff --git a/plugins/crw/skills/crw-run/references/bridge.md b/plugins/crw/skills/crw-run/references/bridge.md index 4999ead200..414bb0f090 100644 --- a/plugins/crw/skills/crw-run/references/bridge.md +++ b/plugins/crw/skills/crw-run/references/bridge.md @@ -230,6 +230,14 @@ Compare actual cwd, workspace roots, model, reasoningEffort, approvalPolicy, and the full sandbox/permission response. Do not infer OCX routing from a model ID or the generic modelProvider label. Missing served-provider proof stays unknown. +A pair may also carry `autoCompactTokenLimit`. The bridge sends it as +`config.model_auto_compact_token_limit` on `thread/start` and `thread/resume`, which is how a +thread is made to compact below a host window larger than what the provider behind the model +accepts. It is not a pair half and not something a caller states: the operator writes it on the +role's pair in the execution policy, and a file without it is unchanged. The host does not report +the value back, so the resume observation never compares it, its silence withholds nothing, and the +receipt lists it under `requested` and `unobservable` rather than `verified`. + The receipt also reports the runtime status the host gave before the resume. Where that status is `notLoaded`, it carries `echoIndependence: "not_established"`: the host may apply the settings it was sent while materializing the task, so an agreeing answer cannot be told apart From 9769aeba5907d4c1959a3aad526cfedcfc9db130 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:40:41 +0900 Subject: [PATCH 2/9] CRW-852: carry the pair's autoCompactTokenLimit on every thread start and resume The execution policy's pair may declare an optional positive integer autoCompactTokenLimit. The bridge sends it as config.model_auto_compact_token_limit on thread/start and thread/resume, and the relay resolves it from the policy for every resume it makes from a task's record, including a settings-free resume. A host whose reported window is larger than what the provider behind a model accepts then compacts before the provider cuts the stream. A value that is not a positive integer is refused while the policy is read, through the existing policy error path; absent means today's behaviour and no new refusal name appears. The host never reports the value back, so the resume observation never compares it, its silence withholds nothing, and the receipt records it as requested and unobservable. Fixes the red Devin finding (relay resumes dropped the limit) and the Codex P2 (the crw-run bridge reference now states the recommended value and its evidence). --- docs/role-execution-policy.md | 11 ++- internal/bridge/autocompact_test.go | 46 +++++++++++++ internal/bridge/execution/autocompact_test.go | 2 +- internal/relay/adapter/autocompact.go | 62 +++++++++++++++++ internal/relay/adapter/autocompact_test.go | 69 ++++++++++++++++++- internal/relay/adapter/transport.go | 5 ++ plugins/crw/.codex-plugin/plugin.json | 2 +- .../crw/skills/crw-run/references/bridge.md | 22 ++++-- 8 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 internal/relay/adapter/autocompact.go diff --git a/docs/role-execution-policy.md b/docs/role-execution-policy.md index dacb7102ef..8de0f7abfd 100644 --- a/docs/role-execution-policy.md +++ b/docs/role-execution-policy.md @@ -157,9 +157,18 @@ behaves exactly as before — no key is sent and the host's own threshold stands exception name no pair, so neither can carry one: a `supervisor` entry that declares the field does not load, and so does an entry that puts it beside `pairs` instead of inside one. +The relay reads the same declaration. A task's recorded settings come from the host's creation +response, which never carries this value, so a relay delivery or correction send resolves the +recipient's role from its record and the limit from the pair that record states, and adds it to the +resume's config — including on a settings-free resume, which transmits no pair but may still carry +the limit of the pair the record says the thread is on. A record on a pair that declares no limit +sends none rather than inheriting a sibling pair's, and a record citing an exception sends none. + A value that is not a positive integer is refused when the file is read, through the same `execution_policy_unreadable` path as every other unusable policy value: a string, a boolean, a -fraction or an exponent, zero and a negative number all fail there. The limit is not part of what a +fraction or an exponent, zero and a negative number all fail there. A number written with an +exponent is refused even when it is integral — `1e6` is a float spelling, not an integer — so a +value the host would accept only after rounding is never silently taken. The limit is not part of what a request is judged against, so it changes neither which pairs a role allows nor the description `get_capabilities` reports — that description is the authorization surface, as it is for MCP profiles. diff --git a/internal/bridge/autocompact_test.go b/internal/bridge/autocompact_test.go index ba1913d87c..3dabcd0d35 100644 --- a/internal/bridge/autocompact_test.go +++ b/internal/bridge/autocompact_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/thisisjun786/codex-relay-workflow/internal/bridge/appserver/fakehost" + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" "github.com/thisisjun786/codex-relay-workflow/internal/bridge/settings" "github.com/thisisjun786/codex-relay-workflow/internal/pyjson" ) @@ -127,3 +128,48 @@ func TestAPolicyWithoutTheLimitSendsNoAutoCompactKey(t *testing.T) { t.Fatalf("settings receipt = %v", receipt["settings"]) } } + +// The third creation path, create_worktree_thread, builds its own contract; it must carry the limit +// too, or a worktree child would be the one creation that keeps the host's larger window. +func TestCreateWorktreeThreadSendsThePairsAutoCompactLimit(t *testing.T) { + b, host := policyBridge(t, autoCompactPolicy) + input := worktreeInput(t) + input.Role = "child" + worktreeHost(host, input.Destination) + receipt, err := b.CreateWorktreeThread(context.Background(), input) + if err != nil || receipt["status"] != "accepted" || receipt["phase"] != "complete" { + t.Fatalf("receipt=%v err=%v", receipt, err) + } + if got := autoCompactConfig(t, host, "thread/start")[settings.AutoCompactTokenLimitKey]; got != float64(550000) { + t.Fatalf("thread/start config = %v", autoCompactConfig(t, host, "thread/start")) + } + section := autoCompactReceiptSettings(t, receipt) + if !autoCompactListed(section["unobservable"], settings.AutoCompactTokenLimitKey) { + t.Fatalf("settings receipt = %v", section) + } +} + +// An exception is not a pair, so it carries no limit even when the role's pair does. +func TestAnExceptionCarriesNoAutoCompactLimit(t *testing.T) { + b, host := testBridge(t) + cwd := t.TempDir() + policy := `{"roles":{"child":{"model":"explicit-model","reasoningEffort":"high","autoCompactTokenLimit":550000}},"exceptions":{"one-task":{"model":"gpt-6-astra","reasoningEffort":"high","cwd":[` + jsonQuote(cwd) + `],"role":"child"}}}` + loaded, err := execution.FromBytes([]byte(policy), "test") + if err != nil { + t.Fatal(err) + } + b.Policy = loaded + start := startReply(cwd) + start.Result["model"] = "gpt-6-astra" + host.Respond("thread/start", start) + host.Respond("turn/start", fakehost.Reply{Result: map[string]any{"turn": map[string]any{"id": "turn-1"}}}) + input := createInput(cwd, "auto-compact-exception") + input.Role, input.Model, input.Effort, input.Exception = "child", "gpt-6-astra", "high", "one-task" + receipt, err := b.CreateThread(context.Background(), input) + if err != nil || receipt["status"] != "accepted" { + t.Fatalf("receipt=%v err=%v", receipt, err) + } + if _, present := autoCompactConfig(t, host, "thread/start")[settings.AutoCompactTokenLimitKey]; present { + t.Fatalf("an exception carried the role pair's limit: %v", autoCompactConfig(t, host, "thread/start")) + } +} diff --git a/internal/bridge/execution/autocompact_test.go b/internal/bridge/execution/autocompact_test.go index b6c2d27abc..b15c4d29ef 100644 --- a/internal/bridge/execution/autocompact_test.go +++ b/internal/bridge/execution/autocompact_test.go @@ -42,7 +42,7 @@ func TestAutoCompactTokenLimitAbsentLeavesThePolicyAsItWas(t *testing.T) { // A value that is not a positive integer is refused while the file is read, through the policy error // every other unusable value is refused with. No new refusal name appears. func TestAutoCompactTokenLimitIsRefusedWhenItIsNotAPositiveInteger(t *testing.T) { - for _, value := range []string{`"550000"`, `0`, `-1`, `550000.0`, `1.5e6`, `true`, `null`} { + for _, value := range []string{`"550000"`, `0`, `-1`, `550000.0`, `1.5e6`, `1e6`, `true`, `null`, `NaN`, `Infinity`, `99999999999999999999`} { t.Run(value, func(t *testing.T) { raw := []byte(`{"roles":{"child":{"model":"` + model + `","reasoningEffort":"` + effort + `","autoCompactTokenLimit":` + value + `}}}`) _, err := FromBytes(raw, "test-policy") diff --git a/internal/relay/adapter/autocompact.go b/internal/relay/adapter/autocompact.go new file mode 100644 index 0000000000..3420c78275 --- /dev/null +++ b/internal/relay/adapter/autocompact.go @@ -0,0 +1,62 @@ +package adapter + +import ( + bridgesettings "github.com/thisisjun786/codex-relay-workflow/internal/bridge/settings" + "github.com/thisisjun786/codex-relay-workflow/internal/relay/delivery" +) + +// autoCompactLimit is the model_auto_compact_token_limit a resume of this record carries. +// +// The host never reports the value back, so it is not part of a task's recorded settings, and a +// resume built from that record alone would drop it: a child created under a pair that declares a +// limit would return to the host's own (larger) window the first time it was unloaded and resumed. +// The relay's delivery and correction sends build their parameters from the record, so the limit is +// resolved here instead, from the policy, by the pair the record states. +// +// A record that cites an exception, names no role, or names a role the policy does not declare +// sends none: an exception and a supervisor name no pair, and the policy file is the only place a +// limit is written. Where the role lists several pairs the record's own pair says which one it is, +// and a pair that declares no limit sends none rather than inheriting a sibling pair's. +func (a *Adapter) autoCompactLimit(record *delivery.TaskSettings) *int64 { + if a.bridge == nil || record == nil { + return nil + } + if cited, _ := record.Data.Lookup("citedException"); cited != nil { + return nil + } + named, _ := record.Data.Lookup("citedRole") + role, _ := named.(string) + if role == "" { + return nil + } + declared, ok := a.bridge.Policy.Role(role) + if !ok { + return nil + } + model, _ := record.Data.Lookup("model") + effort, _ := record.Data.Lookup("reasoningEffort") + modelText, _ := model.(string) + effortText, _ := effort.(string) + for _, pair := range declared.Pairs { + if pair.Model == modelText && pair.Effort == effortText { + return pair.AutoCompactTokenLimit + } + } + return nil +} + +// withAutoCompactLimit adds the record's pair limit to a resume's parameters. It is additive: the +// config object a resume already carries keeps every key it had, and a record whose pair declares no +// limit adds nothing. +func (a *Adapter) withAutoCompactLimit(record *delivery.TaskSettings, params map[string]any) { + limit := a.autoCompactLimit(record) + if limit == nil { + return + } + config, _ := params["config"].(map[string]any) + if config == nil { + config = map[string]any{} + } + config[bridgesettings.AutoCompactTokenLimitKey] = *limit + params["config"] = config +} diff --git a/internal/relay/adapter/autocompact_test.go b/internal/relay/adapter/autocompact_test.go index 3a01179fe7..a612170691 100644 --- a/internal/relay/adapter/autocompact_test.go +++ b/internal/relay/adapter/autocompact_test.go @@ -3,14 +3,17 @@ package adapter import ( "context" "encoding/json" + "fmt" "os" "path/filepath" "sync" "testing" + "github.com/thisisjun786/codex-relay-workflow/internal/bridge" "github.com/thisisjun786/codex-relay-workflow/internal/bridge/appserver/fakehost" "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" "github.com/thisisjun786/codex-relay-workflow/internal/bridge/settings" + "github.com/thisisjun786/codex-relay-workflow/internal/contract" "github.com/thisisjun786/codex-relay-workflow/internal/pyjson" "github.com/thisisjun786/codex-relay-workflow/internal/relay/delivery" "github.com/thisisjun786/codex-relay-workflow/internal/relay/managed" @@ -18,7 +21,7 @@ import ( ) // autoCompactManagedPolicy is the child pair a managed start creates under, carrying the limit this -// issue recommends. Managed start reaches the bridge's settings contract, so the value the operator +// issue recommends. Managed start reaches the bridge settings contract, so the value the operator // puts on the pair is what the creation sends. const autoCompactManagedPolicy = `{"roles":{"parent":{"model":"gpt-5.4","reasoningEffort":"medium"},"child":{"model":"gpt-5.4","reasoningEffort":"medium","autoCompactTokenLimit":550000}}}` @@ -131,3 +134,67 @@ func TestManagedStartSendsTheChildPairsAutoCompactLimit(t *testing.T) { t.Fatalf("thread/start config = %v", config) } } + +// autoCompactChildPolicy is the child role a relay delivery is judged against: the pair the record +// states carries the limit and a second pair does not, so a record on that pair must not inherit its +// sibling's value. The pair's model is taken from the record itself, so this fixture cannot drift +// away from the settings the send is verifying. +func autoCompactChildPolicy(t *testing.T, record *delivery.TaskSettings) bridge.ExecutionPolicy { + t.Helper() + model, _ := record.Data.Lookup("model") + body := fmt.Sprintf(`{"roles":{"child":{"pairs":[{"model":%q,"reasoningEffort":"xhigh","autoCompactTokenLimit":550000},{"model":"gpt-6.1-sol","reasoningEffort":"xhigh"}]}}}`, model) + p, err := execution.FromBytes([]byte(body), "test") + if err != nil { + t.Fatal(err) + } + return p +} + +// A relay delivery resumes a child from its recorded settings, and that record cannot hold this value +// because the host never reports it. The limit is resolved from the policy by the pair the record +// states, so an unloaded child comes back under its pair's threshold rather than the host's window -- +// on both routes, including the settings-free resume that transmits no pair. +func TestARelayDeliveryResumeCarriesTheRecordedPairsAutoCompactLimit(t *testing.T) { + for _, free := range []bool{false, true} { + t.Run(map[bool]string{false: "with-pair", true: "settings-free"}[free], func(t *testing.T) { + record := childRecord(free, "") + rpc := &mcpRPC{} + a := mcpAdapter(t, rpc, autoCompactChildPolicy(t, record)) + if receipt := sendRecord(t, a, "send-auto-compact", record); receipt["status"] != "accepted" { + t.Fatalf("receipt=%v", receipt) + } + if got := resumeConfig(rpc)[settings.AutoCompactTokenLimitKey]; got != int64(550000) { + t.Fatalf("thread/resume config = %v", resumeConfig(rpc)) + } + }) + } +} + +// A record on the role's other pair sends no limit: the value belongs to the pair, and a pair that +// declares none must not inherit its sibling's. +func TestARelayDeliveryOnAPairWithoutALimitSendsNone(t *testing.T) { + record := childRecord(false, "") + record.Data = delivery.Obj(contract.OrderedObject(record.Data).Set("model", "gpt-6.1-sol")) + rpc := &mcpRPC{model: "gpt-6.1-sol"} + a := mcpAdapter(t, rpc, autoCompactChildPolicy(t, childRecord(false, ""))) + if receipt := sendRecord(t, a, "send-other-pair", record); receipt["status"] != "accepted" { + t.Fatalf("receipt=%v", receipt) + } + if _, present := resumeConfig(rpc)[settings.AutoCompactTokenLimitKey]; present { + t.Fatalf("thread/resume config = %v", resumeConfig(rpc)) + } +} + +// A record citing an exception names no pair, so no limit is derived for it. +func TestARelayDeliveryCitingAnExceptionSendsNoLimit(t *testing.T) { + record := childRecord(false, "") + record.Data = delivery.Obj(contract.OrderedObject(record.Data).Set("citedException", "one-task")) + rpc := &mcpRPC{} + a := mcpAdapter(t, rpc, autoCompactChildPolicy(t, record)) + if receipt := sendRecord(t, a, "send-excepted", record); receipt["status"] != "accepted" { + t.Fatalf("receipt=%v", receipt) + } + if _, present := resumeConfig(rpc)[settings.AutoCompactTokenLimitKey]; present { + t.Fatalf("an exception carried a pair-derived limit: %v", resumeConfig(rpc)) + } +} diff --git a/internal/relay/adapter/transport.go b/internal/relay/adapter/transport.go index bd0efbb23b..c899413c68 100644 --- a/internal/relay/adapter/transport.go +++ b/internal/relay/adapter/transport.go @@ -335,6 +335,11 @@ func (a *Adapter) guardedSend(ctx context.Context, requestID, thread, message st params["config"] = expectedMCP.Overrides() } } + // The limit is not in the record (the host never reports it back), so a resume built from the + // record would drop it and the thread would return to the host's own window. It is resolved + // from the policy by the pair the record states, on both routes: a settings-free resume sends + // no pair, but it may still send the limit of the pair the record says the thread is on. + a.withAutoCompactLimit(send, params) if client, ok := a.rpc.(*appserver.Client); ok { watch, err = client.WatchTurn(ctx, thread) if err = awaited(err); err != nil { diff --git a/plugins/crw/.codex-plugin/plugin.json b/plugins/crw/.codex-plugin/plugin.json index 3ef6608e14..3272e75db9 100644 --- a/plugins/crw/.codex-plugin/plugin.json +++ b/plugins/crw/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "crw", - "version": "0.4.0+d7eef67ba94b", + "version": "0.4.0+343105644351", "description": "Linear-driven workflow skills for Codex: define an initiative, plan projects and issues, run delivery, and check the result against its canonical criteria.", "author": { "name": "thisisjun786", diff --git a/plugins/crw/skills/crw-run/references/bridge.md b/plugins/crw/skills/crw-run/references/bridge.md index 414bb0f090..b12ff95d48 100644 --- a/plugins/crw/skills/crw-run/references/bridge.md +++ b/plugins/crw/skills/crw-run/references/bridge.md @@ -231,12 +231,22 @@ the full sandbox/permission response. Do not infer OCX routing from a model ID or the generic modelProvider label. Missing served-provider proof stays unknown. A pair may also carry `autoCompactTokenLimit`. The bridge sends it as -`config.model_auto_compact_token_limit` on `thread/start` and `thread/resume`, which is how a -thread is made to compact below a host window larger than what the provider behind the model -accepts. It is not a pair half and not something a caller states: the operator writes it on the -role's pair in the execution policy, and a file without it is unchanged. The host does not report -the value back, so the resume observation never compares it, its silence withholds nothing, and the -receipt lists it under `requested` and `unobservable` rather than `verified`. +`config.model_auto_compact_token_limit` on `thread/start` and `thread/resume`, and the relay sends +it again on every resume it makes from a task's record, which is how a thread is made to compact +below a host window larger than what the provider behind the model accepts. It is not a pair half +and not something a caller states: the operator writes it on the role's pair in the execution +policy, and a file without it is unchanged. The host does not report the value back, so the resume +observation never compares it, its silence withholds nothing, and the receipt lists it under +`requested` and `unobservable` rather than `verified`. + +**Write 550000.** That is the value measured for `inferhub/deepseek-v4.1-flash`, and it sits about +110,000 below the highest input observed to pass (664,238 tokens) because compaction is decided +between samples and one tool result can add a lot between two of them. The probe behind it: two +ephemeral threads under an isolated `codex app-server`, both reporting a 950,000 window, given the +same ~15,000-token input over three turns. The thread given +`config.model_auto_compact_token_limit=4000` compacted twice, its context falling from 30,696 to +15,478; the control thread never compacted and grew 30,756 to 40,588 to 40,593. A model whose +provider cuts the stream above roughly 664,000 tokens otherwise stops without a receipt. The receipt also reports the runtime status the host gave before the resume. Where that status is `notLoaded`, it carries `echoIndependence: "not_established"`: the host may apply the From 4e651ac66cc6bc4a45d78cdadc2eef617b32f76f Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:15:38 +0900 Subject: [PATCH 3/9] CRW-852: name the limit in a settings-free refusal The audit of this pull request found that a settings-free resume's refusal said "nothing was transmitted" or "only the MCP profile's overrides were transmitted". That resume can now also carry the pair's auto-compaction limit, which is neither a pair nor an MCP override, so the sentence under-reported what went out and sent a reader looking for the wrong cause. verifyResume now names what was actually sent, from a flag the resume sets when it adds the limit. The two wordings that were already there are unchanged, so the captured adapter goldens are unaffected: their policy declares no limit. --- internal/relay/adapter/autocompact.go | 1 + internal/relay/adapter/autocompact_test.go | 33 ++++++++++++++++++++++ internal/relay/adapter/settings.go | 11 +++++++- internal/relay/delivery/settings.go | 4 +++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/internal/relay/adapter/autocompact.go b/internal/relay/adapter/autocompact.go index 3420c78275..316fe89231 100644 --- a/internal/relay/adapter/autocompact.go +++ b/internal/relay/adapter/autocompact.go @@ -59,4 +59,5 @@ func (a *Adapter) withAutoCompactLimit(record *delivery.TaskSettings, params map } config[bridgesettings.AutoCompactTokenLimitKey] = *limit params["config"] = config + record.AutoCompactLimitTransmitted = true } diff --git a/internal/relay/adapter/autocompact_test.go b/internal/relay/adapter/autocompact_test.go index a612170691..1535c2fe81 100644 --- a/internal/relay/adapter/autocompact_test.go +++ b/internal/relay/adapter/autocompact_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "testing" @@ -198,3 +199,35 @@ func TestARelayDeliveryCitingAnExceptionSendsNoLimit(t *testing.T) { t.Fatalf("an exception carried a pair-derived limit: %v", resumeConfig(rpc)) } } + +// A settings-free resume transmits no pair, and its refusal says so. When the limit did go out, the +// sentence has to name it: a reader who was told nothing was transmitted would go looking for a +// different cause, and the limit is exactly what this record could not have carried. +// autoCompactProfilePolicy is the child role the wording test needs: the record's pair carries the +// limit and the role declares the MCP profiles its record names. +func autoCompactProfilePolicy(t *testing.T, record *delivery.TaskSettings) bridge.ExecutionPolicy { + t.Helper() + model, _ := record.Data.Lookup("model") + body := fmt.Sprintf(`{"roles":{"child":{"model":%q,"reasoningEffort":"xhigh","autoCompactTokenLimit":550000,"mcp":{"default":"minimal","profiles":{"minimal":{},"ui-qa":{"servers":["node_repl"]}}}}}}`, model) + p, err := execution.FromBytes([]byte(body), "test") + if err != nil { + t.Fatal(err) + } + return p +} + +func TestASettingsFreeRefusalNamesTheLimitItSent(t *testing.T) { + for _, profile := range []string{"", "ui-qa"} { + t.Run("profile="+profile, func(t *testing.T) { + record := childRecord(true, profile) + rpc := &mcpRPC{configured: []string{"node_repl", "oracle"}, applied: true, model: "other/model"} + a := mcpAdapter(t, rpc, autoCompactProfilePolicy(t, record)) + receipt := sendRecord(t, a, "send-limit-words", record) + rpcError, _ := receipt["rpcError"].(map[string]any) + message, _ := rpcError["message"].(string) + if receipt["status"] != "failed" || !strings.Contains(message, "auto-compaction limit") || strings.Contains(message, "nothing was transmitted") { + t.Fatalf("receipt=%v", receipt) + } + }) + } +} diff --git a/internal/relay/adapter/settings.go b/internal/relay/adapter/settings.go index 2e341b805c..1550f9d1a2 100644 --- a/internal/relay/adapter/settings.go +++ b/internal/relay/adapter/settings.go @@ -25,9 +25,18 @@ func verifyResume(settings delivery.TaskSettings, response any, status any) (con message := code + ": " + pyjson.Text(first.Get("field")) + " returned " + returned + "; message withheld" if !transmitted { code = registry.SettingsFreeRefusalCode(mismatches) + // A settings-free resume transmits no pair, which is what this sentence is about. It may + // still have sent the MCP profile's overrides and the pair's auto-compaction limit, neither + // of which is a pair, so the sentence names what went out rather than counting nothing. sent := "nothing was transmitted" - if _, overrides := settings.Data.Lookup("mcpServers"); overrides { + _, overrides := settings.Data.Lookup("mcpServers") + switch { + case overrides && settings.AutoCompactLimitTransmitted: + sent = "only the MCP profile's overrides and the pair's auto-compaction limit were transmitted" + case overrides: sent = "only the MCP profile's overrides were transmitted" + case settings.AutoCompactLimitTransmitted: + sent = "only the pair's auto-compaction limit was transmitted" } message = code + ": " + pyjson.Text(first.Get("field")) + " is " + returned + " on the loaded thread and " + pyvalue.Repr(first.Get("expected")) + " in the record; " + sent + " and no turn was started" } diff --git a/internal/relay/delivery/settings.go b/internal/relay/delivery/settings.go index 2c5000c9f3..2805839d51 100644 --- a/internal/relay/delivery/settings.go +++ b/internal/relay/delivery/settings.go @@ -15,6 +15,10 @@ import ( type TaskSettings struct { Data Obj SettingsFreeResume bool + // AutoCompactLimitTransmitted is whether this send put the pair's auto-compaction limit in the + // resume config. The limit is not in the record (the host never reports it back), so a refusal + // that describes what a settings-free resume sent has to be told about it rather than infer it. + AutoCompactLimitTransmitted bool } // ResumeParams is the registry's resume_params over this record: only fields ThreadResumeParams From 8b775220f369542f80ead711c1effc8ed9bfd3ea Mon Sep 17 00:00:00 2001 From: crw-934 Date: Wed, 7 Oct 2026 22:58:50 +0900 Subject: [PATCH 4/9] CRW-852: record the auto-compaction limit a relay resume sends The relay's resume resolved the pair's model_auto_compact_token_limit from the policy and put it in the config, but only set a transient flag on the copied settings: the host never reports the value back, so the stored and replayed receipt lost both it and the fact that it went out. The receipt of every relay resume that sends the limit now records it the way the bridge records a setting the host cannot confirm - the value under requested, the field under unobservable, and nothing under verified - and a resume that carried no limit records no such observation. The reference called 550000 a measured value. The probe measured the mechanism at a limit of 4000; 550000 is the recommended margin below the observed provider limit, and both documents now say so. --- docs/role-execution-policy.md | 5 +- internal/bridge/settings/observation.go | 18 +++ internal/relay/adapter/autocompact.go | 16 ++- internal/relay/adapter/autocompact_test.go | 125 ++++++++++++++++++ internal/relay/adapter/json.go | 2 +- internal/relay/adapter/settings.go | 4 +- internal/relay/adapter/transport.go | 10 +- internal/relay/delivery/settings.go | 9 +- plugins/crw/.codex-plugin/plugin.json | 2 +- .../crw/skills/crw-run/references/bridge.md | 34 ++--- 10 files changed, 194 insertions(+), 31 deletions(-) diff --git a/docs/role-execution-policy.md b/docs/role-execution-policy.md index 8de0f7abfd..57c792871f 100644 --- a/docs/role-execution-policy.md +++ b/docs/role-execution-policy.md @@ -178,7 +178,10 @@ resume is not withheld, and its receipt does not fail, because the host said not receipt records it the way it records any setting the host cannot confirm: under `requested`, with the value that was sent, and under `unobservable`; `verified` never lists it. `verification` stays `observed_at_creation` or `observed_at_resume` for the settings that were actually compared, and -`not_requested` when the limit was the only thing asked for. +`not_requested` when the limit was the only thing asked for. A relay resume records it the same way +on the receipt it keeps for its own send: the limit resolved from the record's pair is written under +`requested` and `unobservable`, with `not_requested`, so a later reader of that stored receipt +still sees what the send carried. **The recommended value is 550000**, about 110,000 below the highest input observed to pass (664,238 tokens, 2026-10-06) and about 114,000 below the first observed failure. The margin is there diff --git a/internal/bridge/settings/observation.go b/internal/bridge/settings/observation.go index a4c8592ad3..86f7df0778 100644 --- a/internal/bridge/settings/observation.go +++ b/internal/bridge/settings/observation.go @@ -107,3 +107,21 @@ func (c Contract) Receipt(response map[string]any, at string) map[string]any { sort.Strings(unobservable) return map[string]any{"requested": asked, "actual": Observed(response), "verified": verified, "unobservable": unobservable, "findings": findings, "verification": verification, "observationLimits": observationLimits} } + +// AutoCompactReceipt is the settings observation a resume that carried the pair's limit records. +// The host has no field that reports the value back, so the limit is written in the same notation +// Contract.Receipt uses for any setting the host cannot confirm: the value that went out under +// requested, the field under unobservable, and nothing under verified. The relay's resume path +// resumes from a record rather than a Contract, so it builds this observation itself; a resume that +// carried no limit records none, and a caller must not write one on its behalf. +func AutoCompactReceipt(limit int64, response map[string]any) map[string]any { + return map[string]any{ + "requested": map[string]any{AutoCompactTokenLimitKey: limit}, + "actual": Observed(response), + "verified": []string{}, + "unobservable": []string{AutoCompactTokenLimitKey}, + "findings": []any{}, + "verification": "not_requested", + "observationLimits": observationLimits, + } +} diff --git a/internal/relay/adapter/autocompact.go b/internal/relay/adapter/autocompact.go index 316fe89231..701b9f006f 100644 --- a/internal/relay/adapter/autocompact.go +++ b/internal/relay/adapter/autocompact.go @@ -45,13 +45,16 @@ func (a *Adapter) autoCompactLimit(record *delivery.TaskSettings) *int64 { return nil } -// withAutoCompactLimit adds the record's pair limit to a resume's parameters. It is additive: the -// config object a resume already carries keeps every key it had, and a record whose pair declares no -// limit adds nothing. -func (a *Adapter) withAutoCompactLimit(record *delivery.TaskSettings, params map[string]any) { +// withAutoCompactLimit adds the record's pair limit to a resume's parameters and reports the value it +// put there, nil when the record's pair declares none. It is additive: the config object a resume +// already carries keeps every key it had, and a record whose pair declares no limit adds nothing. +// +// The caller keeps the returned value: the host has no field that reports it back, so the receipt of +// this resume has to record what went out rather than read it from the answer. +func (a *Adapter) withAutoCompactLimit(record *delivery.TaskSettings, params map[string]any) *int64 { limit := a.autoCompactLimit(record) if limit == nil { - return + return nil } config, _ := params["config"].(map[string]any) if config == nil { @@ -59,5 +62,6 @@ func (a *Adapter) withAutoCompactLimit(record *delivery.TaskSettings, params map } config[bridgesettings.AutoCompactTokenLimitKey] = *limit params["config"] = config - record.AutoCompactLimitTransmitted = true + record.AutoCompactTokenLimit = limit + return limit } diff --git a/internal/relay/adapter/autocompact_test.go b/internal/relay/adapter/autocompact_test.go index 1535c2fe81..966b1bb0bb 100644 --- a/internal/relay/adapter/autocompact_test.go +++ b/internal/relay/adapter/autocompact_test.go @@ -231,3 +231,128 @@ func TestASettingsFreeRefusalNamesTheLimitItSent(t *testing.T) { }) } } + +// autoCompactSettings is the receipt's settings observation of a resume, or nil when it carries +// none. It is the same notation the bridge uses for a setting the host cannot confirm: the value +// that went out under requested, and the field under unobservable, never under verified. +func autoCompactSettings(t *testing.T, receipt map[string]any) map[string]any { + t.Helper() + observed, _ := receipt["settings"].(map[string]any) + return observed +} + +// autoCompactSentValue reads the value the receipt recorded as sent, whatever JSON shape it took. +func autoCompactSentValue(t *testing.T, receipt map[string]any) (int64, bool) { + t.Helper() + observed := autoCompactSettings(t, receipt) + if observed == nil { + return 0, false + } + requested, _ := observed["requested"].(map[string]any) + value, present := requested[settings.AutoCompactTokenLimitKey] + return autoCompactInt(value), present +} + +// autoCompactInt reads a number the way the ledger round-trips it: the in-process receipt holds an +// int64, and the stored one holds whatever the decoder made of the same JSON. +func autoCompactInt(value any) int64 { + switch v := value.(type) { + case int64: + return v + case float64: + return int64(v) + case json.Number: + n, _ := v.Int64() + return n + } + return -1 +} + +// autoCompactMarkedUnobservable reports whether the receipt marks the limit as one the host does +// not report back, so a reader never takes its silence for confirmation. +func autoCompactMarkedUnobservable(t *testing.T, receipt map[string]any) bool { + t.Helper() + observed := autoCompactSettings(t, receipt) + if observed == nil { + return false + } + for _, field := range autoCompactStrings(observed["unobservable"]) { + if field == settings.AutoCompactTokenLimitKey { + return true + } + } + return false +} + +// autoCompactStrings reads a list of names from either shape the receipt takes. +func autoCompactStrings(value any) []string { + switch v := value.(type) { + case []string: + return v + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + text, _ := item.(string) + out = append(out, text) + } + return out + } + return nil +} + +// The host never reports the limit back, so a resume that sends it has to say so itself: the value +// that went out is recorded as requested and marked unobservable, exactly as the bridge records a +// setting the host cannot confirm. The record has to survive the ledger, because a later reader sees +// the stored or replayed receipt, not this call's. +func TestARelayResumeReceiptRecordsTheLimitItSentAsUnobservable(t *testing.T) { + for _, free := range []bool{false, true} { + t.Run(map[bool]string{false: "with-pair", true: "settings-free"}[free], func(t *testing.T) { + record := childRecord(free, "") + rpc := &mcpRPC{} + a := mcpAdapter(t, rpc, autoCompactChildPolicy(t, record)) + requestID := "send-limit-receipt" + receipt := sendRecord(t, a, requestID, record) + if receipt["status"] != "accepted" { + t.Fatalf("receipt=%v", receipt) + } + value, present := autoCompactSentValue(t, receipt) + if !present || value != 550000 || !autoCompactMarkedUnobservable(t, receipt) { + t.Fatalf("the receipt does not record the limit it sent as unobservable: %v", receipt) + } + if verified := autoCompactStrings(autoCompactSettings(t, receipt)["verified"]); len(verified) != 0 { + t.Fatalf("the limit was recorded as verified: %v", verified) + } + stored, err := a.GetOperation(context.Background(), requestID) + if err != nil { + t.Fatal(err) + } + value, present = autoCompactSentValue(t, plain(stored).(map[string]any)) + if !present || value != 550000 || !autoCompactMarkedUnobservable(t, plain(stored).(map[string]any)) { + t.Fatalf("the stored receipt lost the limit: %v", stored) + } + replayed := sendRecord(t, a, requestID, record) + if replayed["replayed"] != true { + t.Fatalf("the send was not replayed: %v", replayed) + } + value, present = autoCompactSentValue(t, replayed) + if !present || value != 550000 || !autoCompactMarkedUnobservable(t, replayed) { + t.Fatalf("the replayed receipt lost the limit: %v", replayed) + } + }) + } +} + +// A send whose pair declares no limit records nothing new: a reader of that receipt must not see a +// settings observation it never made. +func TestARelayResumeWithoutALimitRecordsNoSettingsObservation(t *testing.T) { + record := childRecord(false, "") + rpc := &mcpRPC{} + a := mcpAdapter(t, rpc, relayPolicy(t)) + receipt := sendRecord(t, a, "send-no-limit-receipt", record) + if receipt["status"] != "accepted" { + t.Fatalf("receipt=%v", receipt) + } + if observed := autoCompactSettings(t, receipt); observed != nil { + t.Fatalf("a send without a limit recorded a settings observation: %v", observed) + } +} diff --git a/internal/relay/adapter/json.go b/internal/relay/adapter/json.go index 7da684c9b6..f43566edd8 100644 --- a/internal/relay/adapter/json.go +++ b/internal/relay/adapter/json.go @@ -21,7 +21,7 @@ func dumps(value any, sorted bool) string { // returns a copy, so updatedAt is appended after the operation's fields. Nested values are ordered // at the RPC boundary and are not decoded into maps before being persisted. func receiptObject(r ledger.Receipt) contract.OrderedObject { - keys := []string{"requestId", "operation", "status", "startedAt", "retrySafe", "fingerprintVersion", "attempt", "priorAttempts", "threadId", "statusBeforeResume", "resumed", "settingsFreeResume", "settingsFindings", "settingsNotes", "turnId", "attemptedEffects", "error", "rpcError", "updatedAt", "replayed"} + keys := []string{"requestId", "operation", "status", "startedAt", "retrySafe", "fingerprintVersion", "attempt", "priorAttempts", "threadId", "statusBeforeResume", "resumed", "settingsFreeResume", "settings", "settingsFindings", "settingsNotes", "turnId", "attemptedEffects", "error", "rpcError", "updatedAt", "replayed"} if r["operation"] == "create_thread" { keys = []string{"requestId", "operation", "status", "startedAt", "retrySafe", "fingerprintVersion", "attempt", "priorAttempts", "executionPolicy", "threadId", "creation", "settings", "title", "turnId", "desktopProjectAssociation", "error", "rpcError", "attemptedEffects", "updatedAt", "settingsAfterDispatch", "replayed"} } diff --git a/internal/relay/adapter/settings.go b/internal/relay/adapter/settings.go index 1550f9d1a2..a005125bf3 100644 --- a/internal/relay/adapter/settings.go +++ b/internal/relay/adapter/settings.go @@ -31,11 +31,11 @@ func verifyResume(settings delivery.TaskSettings, response any, status any) (con sent := "nothing was transmitted" _, overrides := settings.Data.Lookup("mcpServers") switch { - case overrides && settings.AutoCompactLimitTransmitted: + case overrides && settings.AutoCompactTokenLimit != nil: sent = "only the MCP profile's overrides and the pair's auto-compaction limit were transmitted" case overrides: sent = "only the MCP profile's overrides were transmitted" - case settings.AutoCompactLimitTransmitted: + case settings.AutoCompactTokenLimit != nil: sent = "only the pair's auto-compaction limit was transmitted" } message = code + ": " + pyjson.Text(first.Get("field")) + " is " + returned + " on the loaded thread and " + pyvalue.Repr(first.Get("expected")) + " in the record; " + sent + " and no turn was started" diff --git a/internal/relay/adapter/transport.go b/internal/relay/adapter/transport.go index c899413c68..ee10360b25 100644 --- a/internal/relay/adapter/transport.go +++ b/internal/relay/adapter/transport.go @@ -10,6 +10,7 @@ import ( "github.com/thisisjun786/codex-relay-workflow/internal/bridge/appserver" "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" "github.com/thisisjun786/codex-relay-workflow/internal/bridge/ledger" + bridgesettings "github.com/thisisjun786/codex-relay-workflow/internal/bridge/settings" "github.com/thisisjun786/codex-relay-workflow/internal/contract" "github.com/thisisjun786/codex-relay-workflow/internal/pyjson" "github.com/thisisjun786/codex-relay-workflow/internal/relay/delivery" @@ -339,7 +340,7 @@ func (a *Adapter) guardedSend(ctx context.Context, requestID, thread, message st // record would drop it and the thread would return to the host's own window. It is resolved // from the policy by the pair the record states, on both routes: a settings-free resume sends // no pair, but it may still send the limit of the pair the record says the thread is on. - a.withAutoCompactLimit(send, params) + limit := a.withAutoCompactLimit(send, params) if client, ok := a.rpc.(*appserver.Client); ok { watch, err = client.WatchTurn(ctx, thread) if err = awaited(err); err != nil { @@ -352,6 +353,13 @@ func (a *Adapter) guardedSend(ctx context.Context, requestID, thread, message st return err } receipt["resumed"] = resumed + // The host never reports this value back, so the receipt is the only place the send can say + // what it carried: recorded as requested and unobservable, never as verified, in the same + // notation the bridge uses. A resume that carried no limit records no settings observation. + if limit != nil { + observed, _ := plain(resumed).(map[string]any) + receipt["settings"] = bridgesettings.AutoCompactReceipt(*limit, observed) + } if settings.SettingsFreeResume { receipt["settingsFreeResume"] = true } diff --git a/internal/relay/delivery/settings.go b/internal/relay/delivery/settings.go index 2805839d51..ed8b248682 100644 --- a/internal/relay/delivery/settings.go +++ b/internal/relay/delivery/settings.go @@ -15,10 +15,11 @@ import ( type TaskSettings struct { Data Obj SettingsFreeResume bool - // AutoCompactLimitTransmitted is whether this send put the pair's auto-compaction limit in the - // resume config. The limit is not in the record (the host never reports it back), so a refusal - // that describes what a settings-free resume sent has to be told about it rather than infer it. - AutoCompactLimitTransmitted bool + // AutoCompactTokenLimit is the pair's auto-compaction limit this send put in the resume config, + // nil when it sent none. The limit is not in the record (the host never reports it back), so both + // a refusal that describes what a settings-free resume sent and the receipt that has to record + // the value as unobservable are told about it rather than infer it from the record. + AutoCompactTokenLimit *int64 } // ResumeParams is the registry's resume_params over this record: only fields ThreadResumeParams diff --git a/plugins/crw/.codex-plugin/plugin.json b/plugins/crw/.codex-plugin/plugin.json index 1505ced97d..fdb5f8de80 100644 --- a/plugins/crw/.codex-plugin/plugin.json +++ b/plugins/crw/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "crw", - "version": "0.4.0+3c7e25c508a3", + "version": "0.4.0+4a39c4b34851", "description": "Linear-driven workflow skills for Codex: define an initiative, plan projects and issues, run delivery, and check the result against its canonical criteria.", "author": { "name": "thisisjun786", diff --git a/plugins/crw/skills/crw-run/references/bridge.md b/plugins/crw/skills/crw-run/references/bridge.md index b12ff95d48..2ff0efeab3 100644 --- a/plugins/crw/skills/crw-run/references/bridge.md +++ b/plugins/crw/skills/crw-run/references/bridge.md @@ -232,21 +232,25 @@ or the generic modelProvider label. Missing served-provider proof stays unknown. A pair may also carry `autoCompactTokenLimit`. The bridge sends it as `config.model_auto_compact_token_limit` on `thread/start` and `thread/resume`, and the relay sends -it again on every resume it makes from a task's record, which is how a thread is made to compact -below a host window larger than what the provider behind the model accepts. It is not a pair half -and not something a caller states: the operator writes it on the role's pair in the execution -policy, and a file without it is unchanged. The host does not report the value back, so the resume -observation never compares it, its silence withholds nothing, and the receipt lists it under -`requested` and `unobservable` rather than `verified`. - -**Write 550000.** That is the value measured for `inferhub/deepseek-v4.1-flash`, and it sits about -110,000 below the highest input observed to pass (664,238 tokens) because compaction is decided -between samples and one tool result can add a lot between two of them. The probe behind it: two -ephemeral threads under an isolated `codex app-server`, both reporting a 950,000 window, given the -same ~15,000-token input over three turns. The thread given -`config.model_auto_compact_token_limit=4000` compacted twice, its context falling from 30,696 to -15,478; the control thread never compacted and grew 30,756 to 40,588 to 40,593. A model whose -provider cuts the stream above roughly 664,000 tokens otherwise stops without a receipt. +it again on the resume it makes when it delivers a message to a task or carries a correction back +to it. It is how a thread is made to compact below a host window larger than what the provider +behind the model accepts. It is not a pair half and not something a caller states: the operator +writes it on the role's pair in the execution policy, and a file without it is unchanged. The host +does not report the value back, so the resume observation never compares it, its silence withholds +nothing, and the receipt lists it under `requested` and `unobservable` rather than `verified` -- +on a relay resume too, where the value is resolved from the policy by the pair the task's record +states and the receipt is the only place the send can say what it carried. A resume that sent no +limit records no such observation. + +**Write 550000.** The probe measured the mechanism, not this number: at +`config.model_auto_compact_token_limit=4000` the host compacted, and 550000 is the value +recommended for `inferhub/deepseek-v4.1-flash` because it sits about 110,000 below the highest +input observed to pass (664,238 tokens) and compaction is decided between samples, where one tool +result can add a lot between two of them. The probe: two ephemeral threads under an isolated +`codex app-server`, both reporting a 950,000 window, given the same ~15,000-token input over three +turns. The thread given the 4000 limit compacted twice, its context falling from 30,696 to 15,478; +the control thread never compacted and grew 30,756 to 40,588 to 40,593. A model whose provider cuts +the stream above roughly 664,000 tokens otherwise stops without a receipt. The receipt also reports the runtime status the host gave before the resume. Where that status is `notLoaded`, it carries `echoIndependence: "not_established"`: the host may apply the From e9271aa704dd12cb2ef80aec02c2160c36dddae8 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:34:09 +0900 Subject: [PATCH 5/9] CRW-852: read the limit from the role the gate bound the task to A record need not cite its own role: the sender's gate resolves the role the task is bound to from the store binding and authorizes the send against that role's pair. Reading the role only from the record's own citedRole found none and sent no auto-compaction limit on a delivery the gate had accepted, which is the case the limit exists to cover. The role the gate confirmed now travels with the send, and the limit is read from it before the record's citation. The registry exposes it from the same read that authorized the send, the two settings loaders report it, the MCP copy keeps it, and the supervisor's settings fence compares it so a re-read that lands on a different binding still cancels the claim. --- internal/relay/adapter/autocompact.go | 13 +++- internal/relay/adapter/autocompact_test.go | 39 ++++++++++ internal/relay/adapter/cli.go | 4 +- internal/relay/adapter/daemon.go | 4 +- internal/relay/adapter/mcp_profile.go | 4 +- .../delivery/autocompact_bound_role_test.go | 73 +++++++++++++++++++ internal/relay/delivery/settings.go | 8 +- internal/relay/registry/record.go | 19 +++-- .../supervisor/autocompact_bound_role_test.go | 28 +++++++ internal/relay/supervisor/proposal.go | 5 +- 10 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 internal/relay/delivery/autocompact_bound_role_test.go create mode 100644 internal/relay/supervisor/autocompact_bound_role_test.go diff --git a/internal/relay/adapter/autocompact.go b/internal/relay/adapter/autocompact.go index 701b9f006f..5cce1b26f7 100644 --- a/internal/relay/adapter/autocompact.go +++ b/internal/relay/adapter/autocompact.go @@ -17,6 +17,12 @@ import ( // sends none: an exception and a supervisor name no pair, and the policy file is the only place a // limit is written. Where the role lists several pairs the record's own pair says which one it is, // and a pair that declares no limit sends none rather than inheriting a sibling pair's. +// +// The role is the one the sender's gate confirmed the task is bound to (record.BoundRole) when it +// has one: a record need not cite its own role, and the gate authorizes the send against the bound +// role, so reading only the record's citation would send no limit on a delivery the gate accepted. +// A record that cites a role and was bound to none keeps using the citation, which is the bridge's +// own path (the bridge reads a caller's request, not a binding). func (a *Adapter) autoCompactLimit(record *delivery.TaskSettings) *int64 { if a.bridge == nil || record == nil { return nil @@ -24,8 +30,11 @@ func (a *Adapter) autoCompactLimit(record *delivery.TaskSettings) *int64 { if cited, _ := record.Data.Lookup("citedException"); cited != nil { return nil } - named, _ := record.Data.Lookup("citedRole") - role, _ := named.(string) + role := record.BoundRole + if role == "" { + named, _ := record.Data.Lookup("citedRole") + role, _ = named.(string) + } if role == "" { return nil } diff --git a/internal/relay/adapter/autocompact_test.go b/internal/relay/adapter/autocompact_test.go index 966b1bb0bb..f574ceb5bf 100644 --- a/internal/relay/adapter/autocompact_test.go +++ b/internal/relay/adapter/autocompact_test.go @@ -356,3 +356,42 @@ func TestARelayResumeWithoutALimitRecordsNoSettingsObservation(t *testing.T) { t.Fatalf("a send without a limit recorded a settings observation: %v", observed) } } + +// autoCompactBoundRecord is a record registered without a cited role: the sender's role gate bound +// the task to the role and authorized the send against it, but the record itself cites none. +func autoCompactBoundRecord() *delivery.TaskSettings { + record := childRecord(false, "") + data := delivery.Obj{} + for _, field := range record.Data { + if field.Key == "citedRole" { + continue + } + data = append(data, field) + } + record.Data = data + record.BoundRole = "child" + return record +} + +// A record that cites no role still has one: the sender's role gate resolved the role the task is +// bound to and authorized this very send against that role's pair, so the limit is read from it. +// Reading only the record's own citation would send no limit on a delivery the gate accepted, which +// is the case this feature exists to cover. +func TestARelayResumeReadsTheLimitFromTheBoundRoleTheGateConfirmed(t *testing.T) { + record := autoCompactBoundRecord() + if _, cited := record.Data.Lookup("citedRole"); cited { + t.Fatal("the fixture still cites a role") + } + rpc := &mcpRPC{} + a := mcpAdapter(t, rpc, autoCompactChildPolicy(t, record)) + receipt := sendRecord(t, a, "send-bound-role", record) + if receipt["status"] != "accepted" { + t.Fatalf("receipt=%v", receipt) + } + if got := resumeConfig(rpc)[settings.AutoCompactTokenLimitKey]; got != int64(550000) { + t.Fatalf("thread/resume config = %v", resumeConfig(rpc)) + } + if value, present := autoCompactSentValue(t, receipt); !present || value != 550000 || !autoCompactMarkedUnobservable(t, receipt) { + t.Fatalf("the receipt does not record the limit it sent as unobservable: %v", receipt) + } +} diff --git a/internal/relay/adapter/cli.go b/internal/relay/adapter/cli.go index 01e94ff1b8..3b81020f1a 100644 --- a/internal/relay/adapter/cli.go +++ b/internal/relay/adapter/cli.go @@ -128,11 +128,11 @@ func (f hostFactory) supervisorHostCommand(ctx context.Context, command, state, channel := &supervisor.Channel{Store: s, Linkage: supervisor.StoreLinkage{Store: s}, Program: program, Socket: socket} channel.SettingsLoader = func(ctx context.Context, task string) (*delivery.TaskSettings, error) { r := ®istry.Registry{Store: s, Now: (&delivery.FakeClock{T: now}).ISO, Policy: registry.EnvironmentRolePolicy()} - settings, free, err := r.AuthorizedSettings(ctx, task) + settings, role, free, err := r.AuthorizedSettingsBound(ctx, task) if err != nil { return nil, err } - return &delivery.TaskSettings{Data: delivery.Obj(settings.Data), SettingsFreeResume: free}, nil + return &delivery.TaskSettings{Data: delivery.Obj(settings.Data), SettingsFreeResume: free, BoundRole: role}, nil } switch command { case "supervisor-send": diff --git a/internal/relay/adapter/daemon.go b/internal/relay/adapter/daemon.go index 8601d910e2..be589c9405 100644 --- a/internal/relay/adapter/daemon.go +++ b/internal/relay/adapter/daemon.go @@ -29,11 +29,11 @@ func (f hostFactory) daemonFactory(ctx context.Context, services dispatch.Servic channel := &supervisor.Channel{Store: s, Linkage: supervisor.StoreLinkage{Store: s}, Program: services.Program, Socket: services.SocketPath} channel.SettingsLoader = func(ctx context.Context, task string) (*delivery.TaskSettings, error) { r := ®istry.Registry{Store: s, Now: clock.ISO, Policy: registry.EnvironmentRolePolicy()} - settings, free, err := r.AuthorizedSettings(ctx, task) + settings, role, free, err := r.AuthorizedSettingsBound(ctx, task) if err != nil { return nil, err } - return &delivery.TaskSettings{Data: delivery.Obj(settings.Data), SettingsFreeResume: free}, nil + return &delivery.TaskSettings{Data: delivery.Obj(settings.Data), SettingsFreeResume: free, BoundRole: role}, nil } d := daemon.New(s, a, clock, channel) d.Faults = &faults.Ledger{Store: s, Clock: clock} diff --git a/internal/relay/adapter/mcp_profile.go b/internal/relay/adapter/mcp_profile.go index c1e3028cbb..cfbbc1315b 100644 --- a/internal/relay/adapter/mcp_profile.go +++ b/internal/relay/adapter/mcp_profile.go @@ -42,7 +42,9 @@ func (a *Adapter) withMCP(ctx context.Context, record *delivery.TaskSettings) (* data = append(data, field) } } - send := &delivery.TaskSettings{Data: data, SettingsFreeResume: record.SettingsFreeResume} + // The bound role travels with the copy: the limit is read from it, and this send is the one that + // carries both the profile and the limit. + send := &delivery.TaskSettings{Data: data, SettingsFreeResume: record.SettingsFreeResume, BoundRole: record.BoundRole} stated, _ := record.Data.Lookup("mcpProfile") cited, _ := record.Data.Lookup("citedRole") profile, _ := stated.(string) diff --git a/internal/relay/delivery/autocompact_bound_role_test.go b/internal/relay/delivery/autocompact_bound_role_test.go new file mode 100644 index 0000000000..aba2f7907c --- /dev/null +++ b/internal/relay/delivery/autocompact_bound_role_test.go @@ -0,0 +1,73 @@ +package delivery + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" + "github.com/thisisjun786/codex-relay-workflow/internal/relay/registry" +) + +// A task's record need not cite its own role: the sender's gate resolves the role the task is +// bound to and authorizes the send against that role's pair. A rule that reads the role from the +// record alone finds none and would silently send no auto-compaction limit on a delivery the gate +// accepted, which is exactly the case the limit exists to cover. The gate therefore reports the +// role it authorized against, so the transport reads the pair's limit from that role. +// sequential: t.Setenv(execution.EnvPolicy) is process-wide. +func TestTheDefaultRoleGateReportsTheBoundRoleItAuthorizedAgainst(t *testing.T) { + f := newFixture(t, "") + const task = "01parent-bound" + settings := taskSettings(f.root) + // The policy declares exactly the pair the fixture records, read from the fixture rather than + // restated, so this test cannot drift away from what the gate is asked to authorize. + record := loadsObj(settings) + model, _ := record.Lookup("model") + effort, _ := record.Lookup("reasoningEffort") + declared := fmt.Sprintf(`{"roles": {"parent": {"model": %q, "reasoningEffort": %q, "autoCompactTokenLimit": 550000}}}`, model, effort) + if _, err := execSQL(f.ctx, f.store, "INSERT INTO authorized_settings (task_id, settings, source, recorded_at) VALUES (?,?,?,?)", task, settings, "creation_result", "t"); err != nil { + t.Fatal(err) + } + var last *TaskSettings + gate := func() error { + var err error + last, err = AuthorizedSettings(f.ctx, f.store, task, nil) + return err + } + usePolicy := func(text string) { + t.Helper() + if text == "" { + t.Setenv(execution.EnvPolicy, "") + } else { + path := filepath.Join(t.TempDir(), "execution-policy.json") + if err := os.WriteFile(path, []byte(text), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(execution.EnvPolicy, path) + } + registry.ResetRolePolicySnapshot() + } + t.Cleanup(registry.ResetRolePolicySnapshot) + + usePolicy("") + if err := gate(); err != nil { + t.Fatalf("a task bound to no role is gated: %v", err) + } + if last.BoundRole != "" { + t.Fatalf("a task bound to no role reported bound role %q", last.BoundRole) + } + if _, err := execSQL(f.ctx, f.store, "INSERT INTO scope_bindings (binding_id, role, scope_kind, scope_key, task_id, host_id, status, revision, created_at, updated_at) VALUES ('bnd-1','parent','project','PROJ-1',?,'host-a','active',1,'t','t')", task); err != nil { + t.Fatal(err) + } + usePolicy(declared) + if err := gate(); err != nil { + t.Fatalf("a bound parent whose recorded pair is the policy's is withheld: %v", err) + } + if _, cited := last.Data.Lookup("citedRole"); cited { + t.Fatal("the fixture cites its own role, so it does not exercise the bound-role path") + } + if last.BoundRole != "parent" { + t.Fatalf("BoundRole = %q, want the role the gate authorized against (parent)", last.BoundRole) + } +} diff --git a/internal/relay/delivery/settings.go b/internal/relay/delivery/settings.go index ed8b248682..e44c9a83bd 100644 --- a/internal/relay/delivery/settings.go +++ b/internal/relay/delivery/settings.go @@ -15,6 +15,11 @@ import ( type TaskSettings struct { Data Obj SettingsFreeResume bool + // BoundRole is the role the sender's gate confirmed this task is bound to, "" when it is bound + // to none. A record need not cite its own role, and the gate authorizes the send against the + // bound role rather than the citation, so a rule that reads the role (the pair's auto-compaction + // limit) has to read the role the gate checked rather than only the record's own citation. + BoundRole string // AutoCompactTokenLimit is the pair's auto-compaction limit this send put in the resume config, // nil when it sent none. The limit is not in the record (the host never reports it back), so both // a refusal that describes what a settings-free resume sent and the receipt that has to record @@ -38,9 +43,10 @@ type RoleGate func(ctx context.Context, s *store.Store, taskID string, settings // derived from its role's pair (a supervisor's recorded pair, an exception) is resumed // settings-free, as every other sender resumes it, so a later user selection is never reverted. func DefaultRoleGate(ctx context.Context, s *store.Store, taskID string, settings *TaskSettings) error { - _, settingsFree, err := registry.CheckBoundRole(ctx, s, taskID, settings.Data, registry.EnvironmentRolePolicy()) + role, settingsFree, err := registry.CheckBoundRole(ctx, s, taskID, settings.Data, registry.EnvironmentRolePolicy()) if err == nil { settings.SettingsFreeResume = settingsFree + settings.BoundRole = role } return err } diff --git a/internal/relay/registry/record.go b/internal/relay/registry/record.go index ec74436260..468f893ea7 100644 --- a/internal/relay/registry/record.go +++ b/internal/relay/registry/record.go @@ -229,19 +229,28 @@ func describe(finding contract.OrderedObject) string { // sender that resumes a task, or the pre-send refusal (reason and detail) that withholds it. // settingsFree reports that the pair was not derived from the role's declared pair. func (r *Registry) AuthorizedSettings(ctx context.Context, task string) (settings TaskSettings, settingsFree bool, err error) { + settings, _, settingsFree, err = r.AuthorizedSettingsBound(ctx, task) + return settings, settingsFree, err +} + +// AuthorizedSettingsBound is AuthorizedSettings with the role the task is bound to ("" when it is +// bound to none). A sender that has to read the role for a rule the gate itself does not decide -- +// the pair's auto-compaction limit is resolved from that role's pair -- takes both from this one +// call, so the role and the authorization it was judged with come from the same read. +func (r *Registry) AuthorizedSettingsBound(ctx context.Context, task string) (settings TaskSettings, role string, settingsFree bool, err error) { settings, ok, err := r.LoadSettings(ctx, task) if err != nil { - return settings, false, err + return settings, "", false, err } if !ok { - return settings, false, refuse(contract.RefusalSettingsUnavailable, "no authorized settings recorded for %s; register them from the"+ + return settings, "", false, refuse(contract.RefusalSettingsUnavailable, "no authorized settings recorded for %s; register them from the"+ " creation result before a send can preserve them", pyvalue.StrRepr(task)) } if err := settings.RequireUsable(); err != nil { - return settings, false, err + return settings, "", false, err } - _, settingsFree, err = CheckBoundRole(ctx, r.Store, task, settings.Data, r.Policy) - return settings, settingsFree, err + role, settingsFree, err = CheckBoundRole(ctx, r.Store, task, settings.Data, r.Policy) + return settings, role, settingsFree, err } // CheckBoundRole is the role half of AuthorizedSettings, which every sender's pre-send check diff --git a/internal/relay/supervisor/autocompact_bound_role_test.go b/internal/relay/supervisor/autocompact_bound_role_test.go new file mode 100644 index 0000000000..9a30f1a236 --- /dev/null +++ b/internal/relay/supervisor/autocompact_bound_role_test.go @@ -0,0 +1,28 @@ +package supervisor + +import ( + "testing" + + "github.com/thisisjun786/codex-relay-workflow/internal/relay/delivery" +) + +// The settings fence cancels a claim when a re-read of the recipient's authorized settings differs +// from what the send read. The role the gate confirmed is part of what the send reads: the pair's +// auto-compaction limit is resolved from it, so a re-read that lands on a different binding must +// differ here too, or the transport would run on the pair the first read confirmed while the store +// now says the task is bound to another role. +func TestTheSettingsFenceSeesAChangedBoundRole(t *testing.T) { + data := delivery.Obj{{Key: "model", Value: "anthropic/claude-opus-5"}, {Key: "reasoningEffort", Value: "xhigh"}} + same := func(role string) *delivery.TaskSettings { + return &delivery.TaskSettings{Data: append(delivery.Obj{}, data...), BoundRole: role} + } + if !sameSettings24(same("child"), same("child")) { + t.Fatal("two reads of the same binding differ") + } + if sameSettings24(same("child"), same("parent")) { + t.Fatal("a re-read that landed on another bound role reads as the same settings") + } + if sameSettings24(same("child"), same("")) { + t.Fatal("a re-read that found no bound role reads as the same settings") + } +} diff --git a/internal/relay/supervisor/proposal.go b/internal/relay/supervisor/proposal.go index de5c7caece..c8347319d7 100644 --- a/internal/relay/supervisor/proposal.go +++ b/internal/relay/supervisor/proposal.go @@ -303,7 +303,10 @@ func sameSettings24(first, second *delivery.TaskSettings) bool { if first == nil || second == nil { return first == second } - return first.SettingsFreeResume == second.SettingsFreeResume && pyjson.Dumps(first.Data, pyjson.Options{SortKeys: true}) == pyjson.Dumps(second.Data, pyjson.Options{SortKeys: true}) + // The bound role is part of what a send reads from a record: a re-read that lands on a different + // binding must cancel the claim rather than let the transport run on the pair the first read + // confirmed (the auto-compaction limit is resolved from that role's pair). + return first.SettingsFreeResume == second.SettingsFreeResume && first.BoundRole == second.BoundRole && pyjson.Dumps(first.Data, pyjson.Options{SortKeys: true}) == pyjson.Dumps(second.Data, pyjson.Options{SortKeys: true}) } func optionalNumber(n sql.NullInt64) any { From 4a9a7b7309897885e959153842c7683105fdbb0c Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:27:09 +0900 Subject: [PATCH 6/9] CRW-852: keep a pair's auto-compaction limit across a policy edit The policy editor's setRolePairs rebuilds a role's whole pairs list from the model and effort of each pair the change states. It did not know the pair's autoCompactTokenLimit, so a change that retained a limited pair silently published the same pair without its limit, and a legacy single declaration that carried the limit could not be moved to the pairs form at all: the editor left the limit beside the list, where the parser refuses it. The limit now travels with the pair. A retained pair keeps the limit the document already declared for its model and effort, a legacy declaration's limit follows its one pair into the list, and the read projection shows the limit on the pair that declares it. A pair without a limit is still written without the key. --- .../policystore/autocompact_editor_test.go | 97 +++++++++++++++++++ internal/policystore/check.go | 65 ++++++++++++- internal/policystore/policy.go | 26 ++++- 3 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 internal/policystore/autocompact_editor_test.go diff --git a/internal/policystore/autocompact_editor_test.go b/internal/policystore/autocompact_editor_test.go new file mode 100644 index 0000000000..5bb98a6f18 --- /dev/null +++ b/internal/policystore/autocompact_editor_test.go @@ -0,0 +1,97 @@ +package policystore + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/thisisjun786/codex-relay-workflow/internal/pyjson" +) + +// twoPairsOneLimited is a document whose child role lists two pairs, the first carrying the +// auto-compaction limit this repository now accepts on a pair. +const twoPairsOneLimited = "{\"roles\": {\"child\": {\"pairs\": [" + + "{\"model\": \"inferhub/deepseek-v4.1-flash\", \"reasoningEffort\": \"none\", \"autoCompactTokenLimit\": 550000}, " + + "{\"model\": \"gpt-6.1-sol\", \"reasoningEffort\": \"xhigh\"}]}}}\n" + +// legacyLimited is the documented single declaration carrying the limit. +const legacyLimited = "{\"roles\": {\"child\": {\"model\": \"inferhub/deepseek-v4.1-flash\", \"reasoningEffort\": \"none\", \"autoCompactTokenLimit\": 550000}}}\n" + +// setRolePairs rewrites a role's whole pairs list, so a pair it retains must keep every property +// that pair declared. The auto-compaction limit belongs to the pair, and a change that rebuilt the +// retained pair without it would publish a policy whose limit is silently gone: the operator would +// see a valid document and the provider-limit failure this feature exists to prevent would return. +func TestSetRolePairsKeepsTheLimitOfAPairItRetains(t *testing.T) { + change := Change{Kind: KindSetRolePairs, Role: "child", Pairs: []Pair{ + {Model: "inferhub/deepseek-v4.1-flash", Effort: "none"}, + {Model: "gpt-6.1-sol", Effort: "xhigh"}, + }} + result := Check([]byte(twoPairsOneLimited), digestOf(twoPairsOneLimited), change) + if !result.Valid { + t.Fatalf("retaining a pair the document already declares was refused: %+v", result) + } + candidate, err := candidateOf(t, twoPairsOneLimited, change) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(candidate, "\"autoCompactTokenLimit\": 550000") { + t.Fatalf("the retained pair lost its auto-compaction limit: %s", candidate) + } +} + +// The editor also has to be able to move a legacy single declaration to the pairs form. A limit +// written on the legacy declaration belongs to the one pair it declares, so the candidate must +// carry it inside that pair rather than beside the list, which the parser refuses. +func TestSetRolePairsMovesALegacyLimitIntoItsPair(t *testing.T) { + change := Change{Kind: KindSetRolePairs, Role: "child", Pairs: []Pair{ + {Model: "inferhub/deepseek-v4.1-flash", Effort: "none"}, + {Model: "gpt-6.1-sol", Effort: "xhigh"}, + }} + result := Check([]byte(legacyLimited), digestOf(legacyLimited), change) + if !result.Valid { + t.Fatalf("adding a second pair to a legacy declaration that carries the limit was refused: %+v", result) + } + candidate, err := candidateOf(t, legacyLimited, change) + if err != nil { + t.Fatal(err) + } + document, err := decode([]byte(candidate)) + if err != nil { + t.Fatal(err) + } + roles, _ := document.Get("roles").(pyjson.Object) + entry, _ := roles.Get("child").(pyjson.Object) + if _, present := entry.Lookup("autoCompactTokenLimit"); present { + t.Fatalf("the limit was left beside the pairs list, where the parser refuses it: %s", candidate) + } + pairs, _ := entry.Get("pairs").([]any) + if len(pairs) != 2 { + t.Fatalf("pairs = %v", pairs) + } + first, _ := pairs[0].(pyjson.Object) + limit, present := first.Lookup("autoCompactTokenLimit") + if !present || canonical(limit) != "550000" { + t.Fatalf("the first pair does not carry the legacy limit: %s", candidate) + } +} + +// The read projection is what an operator sees before proposing a change, so a limit the file +// declares has to be visible on the pair it belongs to rather than silently dropped. +func TestReadingShowsTheLimitOnThePairThatDeclaresIt(t *testing.T) { + file := filepath.Join(t.TempDir(), "execution-policy.json") + if err := os.WriteFile(file, []byte(twoPairsOneLimited), 0o644); err != nil { + t.Fatal(err) + } + reading := Read(Located{State: Registered, Path: file}) + role, ok := roleOf(reading, "child") + if !ok { + t.Fatalf("the child role is not projected: %+v", reading.Roles) + } + if len(role.Pairs) != 2 || role.Pairs[0].AutoCompactTokenLimit != 550000 { + t.Fatalf("the projected pairs lost the limit: %+v", role.Pairs) + } + if role.Pairs[1].AutoCompactTokenLimit != 0 { + t.Fatalf("a pair that declares no limit reports one: %+v", role.Pairs) + } +} diff --git a/internal/policystore/check.go b/internal/policystore/check.go index e29d889097..d851214471 100644 --- a/internal/policystore/check.go +++ b/internal/policystore/check.go @@ -10,6 +10,7 @@ import ( "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" "github.com/thisisjun786/codex-relay-workflow/internal/pyjson" + "github.com/thisisjun786/codex-relay-workflow/internal/pyvalue" ) // The five changes a caller may propose. Exactly one is applied per request, and nothing outside @@ -248,23 +249,83 @@ func applyRolePairs(document pyjson.Object, change Change) (pyjson.Object, []str existing, _ := roles.Get(change.Role).(pyjson.Object) entry := pyjson.Object{} for _, field := range existing { - if field.Key == "model" || field.Key == "pairs" || field.Key == "reasoningEffort" { + if field.Key == "model" || field.Key == "pairs" || field.Key == "reasoningEffort" || field.Key == "autoCompactTokenLimit" { continue } entry = append(entry, field) } + // The limit belongs to a pair, and this change rebuilds the whole list. Every limit the role + // declares today is read here, so a pair the change keeps keeps its limit and a legacy single + // declaration's limit follows its one pair into the list rather than being left beside it, + // where the parser refuses it. A change may still state a limit of its own for any pair. + declared := declaredLimits(existing) if len(change.Pairs) == 1 { entry = append(entry, pyjson.Field{Key: "model", Value: change.Pairs[0].Model}, pyjson.Field{Key: "reasoningEffort", Value: change.Pairs[0].Effort}) + if limit, ok := limitFor(change.Pairs[0], declared); ok { + entry = append(entry, pyjson.Field{Key: "autoCompactTokenLimit", Value: limit}) + } } else { list := make([]any, 0, len(change.Pairs)) for _, pair := range change.Pairs { - list = append(list, pyjson.Object{{Key: "model", Value: pair.Model}, {Key: "reasoningEffort", Value: pair.Effort}}) + built := pyjson.Object{{Key: "model", Value: pair.Model}, {Key: "reasoningEffort", Value: pair.Effort}} + if limit, ok := limitFor(pair, declared); ok { + built = append(built, pyjson.Field{Key: "autoCompactTokenLimit", Value: limit}) + } + list = append(list, built) } entry = append(entry, pyjson.Field{Key: "pairs", Value: list}) } return document.Set("roles", roles.Set(change.Role, entry)), []string{"roles." + change.Role}, nil } +// declaredLimits is every autoCompactTokenLimit a role entry declares today: the limit of each pair +// it lists, and the limit beside a legacy single declaration, which belongs to that one pair. The +// key is the pair's own model and effort in two nested maps, so no delimiter inside either name can +// make two different pairs look like one. +func declaredLimits(entry pyjson.Object) map[string]map[string]any { + limits := map[string]map[string]any{} + record := func(pair pyjson.Object) { + limit, present := pair.Lookup("autoCompactTokenLimit") + if !present { + return + } + model, _ := pair.Lookup("model") + effort, _ := pair.Lookup("reasoningEffort") + byEffort := limits[pyvalue.Str(model)] + if byEffort == nil { + byEffort = map[string]any{} + limits[pyvalue.Str(model)] = byEffort + } + byEffort[pyvalue.Str(effort)] = limit + } + if listed, ok := entry.Get("pairs").([]any); ok { + for _, item := range listed { + if pair, ok := item.(pyjson.Object); ok { + record(pair) + } + } + return limits + } + record(entry) + return limits +} + +// limitFor is the limit a rebuilt pair carries: the one the change states for it, or the one the +// document already declared for that same model and effort. The second value is false when neither +// has one, and then no key is written at all, so a pair without a limit is written the way it +// always was rather than gaining an explicit null. +func limitFor(pair Pair, declared map[string]map[string]any) (any, bool) { + if pair.AutoCompactTokenLimit != 0 { + return pair.AutoCompactTokenLimit, true + } + byEffort, ok := declared[pair.Model] + if !ok { + return nil, false + } + limit, ok := byEffort[pair.Effort] + return limit, ok +} + // applyAllowed sets one model's approved efforts. func applyAllowed(document pyjson.Object, change Change) (pyjson.Object, []string, error) { if change.Model == "" { diff --git a/internal/policystore/policy.go b/internal/policystore/policy.go index 3a37a2fe5a..b1fa9fc0b2 100644 --- a/internal/policystore/policy.go +++ b/internal/policystore/policy.go @@ -65,6 +65,11 @@ type Located struct { // spells it, and effort is accepted as an alias. type Pair struct { Model, Effort string + // AutoCompactTokenLimit is the pair's optional model_auto_compact_token_limit, zero when the + // pair declares none. It is not part of the pair's identity - the pair is its model and effort - + // but it does belong to the pair, so a change that rebuilds a role's pairs has to carry it + // across for every pair it keeps. + AutoCompactTokenLimit int64 // conflict is why the two spellings of the effort could not be reconciled, or "" when they // could. It is unexported because it is not part of the pair: it is how a mismatch reaches the // check as a refused change rather than as a decode failure the API would answer as a bad @@ -77,6 +82,9 @@ type pairWire struct { Model string `json:"model"` ReasoningEffort string `json:"reasoningEffort"` Effort string `json:"effort,omitempty"` + // AutoCompactTokenLimit is written only when the pair declares one, so a pair without it reads + // back the way it was written. + AutoCompactTokenLimit int64 `json:"autoCompactTokenLimit,omitempty"` } // pairRequest is a pair as a request carries it. The two effort spellings are raw so the reader can @@ -86,6 +94,9 @@ type pairRequest struct { Model string `json:"model"` ReasoningEffort json.RawMessage `json:"reasoningEffort"` Effort json.RawMessage `json:"effort"` + // AutoCompactTokenLimit is raw so an absent key is distinguishable from a zero one: zero is not + // a positive integer, and the parser refuses it rather than reading it as "no limit". + AutoCompactTokenLimit json.RawMessage `json:"autoCompactTokenLimit"` } // UnmarshalJSON reads a pair from either spelling of its effort. A pair that names neither is read @@ -107,6 +118,13 @@ func (p *Pair) UnmarshalJSON(raw []byte) error { } p.Model = wire.Model p.Effort, p.conflict = effortAlias(reasoningEffort, reasoningPresent, effort, effortPresent) + if len(wire.AutoCompactTokenLimit) > 0 { + var limit int64 + if err := json.Unmarshal(wire.AutoCompactTokenLimit, &limit); err != nil { + return err + } + p.AutoCompactTokenLimit = limit + } return nil } @@ -141,7 +159,7 @@ func effortAlias(reasoningEffort string, reasoningPresent bool, effort string, e // MarshalJSON writes a pair as the policy document spells it. func (p Pair) MarshalJSON() ([]byte, error) { - return json.Marshal(pairWire{Model: p.Model, ReasoningEffort: p.Effort}) + return json.Marshal(pairWire{Model: p.Model, ReasoningEffort: p.Effort, AutoCompactTokenLimit: p.AutoCompactTokenLimit}) } // RoleView is one declared role: its pairs, or the record expectation of a supervisor. The JSON @@ -290,7 +308,11 @@ func projectRoles(policy execution.Policy) []RoleView { } view := RoleView{Name: name, Expectation: role.Expectation} for _, pair := range role.Pairs { - view.Pairs = append(view.Pairs, Pair{Model: pair.Model, Effort: pair.Effort}) + limit := int64(0) + if pair.AutoCompactTokenLimit != nil { + limit = *pair.AutoCompactTokenLimit + } + view.Pairs = append(view.Pairs, Pair{Model: pair.Model, Effort: pair.Effort, AutoCompactTokenLimit: limit}) } roles = append(roles, view) } From 569d5180ff3fa8f052f2dfd2db0a7a58bc4ce42f Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:36:59 +0900 Subject: [PATCH 7/9] CRW-852: judge a supplied auto-compaction limit where it is supplied The policy editor's pair type collapsed a supplied autoCompactTokenLimit into an int64, so a request that stated 0 or null was read as "no limit": the check approved a document whose pair silently kept an older limit, or dropped the invalid value. A fraction or a string failed the decode instead, which the API answers as a bad request rather than as the refused change the rule is. The pair now keeps the value exactly as the request spelled it, and the editor writes that spelling into the candidate document, where the same parser rule the policy file is held to refuses it. A pair that declares no limit is still written without the key. Both documents now also state the issue's measured application restriction: a resume does not install the value on a thread the host already holds; it applies at creation or on the next load, so an accepted resume receipt records that the limit was sent, not that it applies. --- docs/role-execution-policy.md | 7 ++++++ .../policystore/autocompact_editor_test.go | 21 ++++++++++++++++ internal/policystore/check.go | 4 +-- internal/policystore/policy.go | 25 ++++++++++++++++--- plugins/crw/.codex-plugin/plugin.json | 2 +- .../crw/skills/crw-run/references/bridge.md | 5 ++++ 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/docs/role-execution-policy.md b/docs/role-execution-policy.md index 57c792871f..16f28d460a 100644 --- a/docs/role-execution-policy.md +++ b/docs/role-execution-policy.md @@ -183,6 +183,13 @@ on the receipt it keeps for its own send: the limit resolved from the record's p `requested` and `unobservable`, with `not_requested`, so a later reader of that stored receipt still sees what the send carried. +**A resume does not install the value on a thread the host already has loaded.** The host answers a +resume with the thread's current state, so a resume that transmits the limit to a loaded thread +leaves that thread's own threshold as it was: the value takes effect at creation, or when the host +loads the thread again (a `notLoaded` recipient). An accepted resume receipt therefore records that the +limit was sent, not that it now applies. An operator who adds the value for a running child gets it +on that child's next load. + **The recommended value is 550000**, about 110,000 below the highest input observed to pass (664,238 tokens, 2026-10-06) and about 114,000 below the first observed failure. The margin is there because compaction is decided between samples, and one tool result can add a lot between two of diff --git a/internal/policystore/autocompact_editor_test.go b/internal/policystore/autocompact_editor_test.go index 5bb98a6f18..b5e4a8898c 100644 --- a/internal/policystore/autocompact_editor_test.go +++ b/internal/policystore/autocompact_editor_test.go @@ -1,6 +1,7 @@ package policystore import ( + "encoding/json" "os" "path/filepath" "strings" @@ -95,3 +96,23 @@ func TestReadingShowsTheLimitOnThePairThatDeclaresIt(t *testing.T) { t.Fatalf("a pair that declares no limit reports one: %+v", role.Pairs) } } + +// A supplied limit is judged where it is supplied. Zero and null are not "no limit": they are +// values that are not positive integers, and the same rule the policy file is held to applies to a +// request that states one. Collapsing them into the absent representation would let the check +// approve a document whose pair silently kept an older limit, or dropped the invalid one. +func TestSetRolePairsRefusesASuppliedLimitThatIsNotAPositiveInteger(t *testing.T) { + for _, supplied := range []string{"0", "-1", "null", "1.5", "\"550000\""} { + t.Run(supplied, func(t *testing.T) { + raw := "{\"kind\":\"setRolePairs\",\"role\":\"child\",\"pairs\":[{\"model\":\"inferhub/deepseek-v4.1-flash\",\"reasoningEffort\":\"none\",\"autoCompactTokenLimit\":" + supplied + "}]}" + var change Change + if err := json.Unmarshal([]byte(raw), &change); err != nil { + t.Fatalf("a supplied limit must be judged by Check, not fail the decode: %v", err) + } + result := Check([]byte(legacyLimited), digestOf(legacyLimited), change) + if result.Valid { + t.Fatalf("autoCompactTokenLimit %s was accepted: %+v", supplied, result) + } + }) + } +} diff --git a/internal/policystore/check.go b/internal/policystore/check.go index d851214471..d852712a06 100644 --- a/internal/policystore/check.go +++ b/internal/policystore/check.go @@ -315,8 +315,8 @@ func declaredLimits(entry pyjson.Object) map[string]map[string]any { // has one, and then no key is written at all, so a pair without a limit is written the way it // always was rather than gaining an explicit null. func limitFor(pair Pair, declared map[string]map[string]any) (any, bool) { - if pair.AutoCompactTokenLimit != 0 { - return pair.AutoCompactTokenLimit, true + if pair.limitPresent { + return pair.limit, true } byEffort, ok := declared[pair.Model] if !ok { diff --git a/internal/policystore/policy.go b/internal/policystore/policy.go index b1fa9fc0b2..9d4a4a4ad7 100644 --- a/internal/policystore/policy.go +++ b/internal/policystore/policy.go @@ -70,6 +70,13 @@ type Pair struct { // but it does belong to the pair, so a change that rebuilds a role's pairs has to carry it // across for every pair it keeps. AutoCompactTokenLimit int64 + // limit is the same field exactly as a request spelled it, present only when the request named the + // key. A request that states zero, null, a fraction or a string has stated a value, and the rule + // the policy file is held to applies to it: the value is carried into the candidate document and + // refused there. Collapsing it into the absent representation would approve a document whose pair + // silently kept an older limit, or quietly dropped the invalid one. + limit any + limitPresent bool // conflict is why the two spellings of the effort could not be reconciled, or "" when they // could. It is unexported because it is not part of the pair: it is how a mismatch reaches the // check as a refused change rather than as a decode failure the API would answer as a bad @@ -119,11 +126,16 @@ func (p *Pair) UnmarshalJSON(raw []byte) error { p.Model = wire.Model p.Effort, p.conflict = effortAlias(reasoningEffort, reasoningPresent, effort, effortPresent) if len(wire.AutoCompactTokenLimit) > 0 { - var limit int64 - if err := json.Unmarshal(wire.AutoCompactTokenLimit, &limit); err != nil { + value, err := pyjson.Loads(string(wire.AutoCompactTokenLimit), pyjson.LoadOptions{Constants: true, Numbers: pyjson.SpelledNumbers}) + if err != nil { return err } - p.AutoCompactTokenLimit = limit + p.limit, p.limitPresent = value, true + if spelled, ok := value.(json.Number); ok { + if parsed, err := strconv.ParseInt(string(spelled), 10, 64); err == nil { + p.AutoCompactTokenLimit = parsed + } + } } return nil } @@ -159,6 +171,13 @@ func effortAlias(reasoningEffort string, reasoningPresent bool, effort string, e // MarshalJSON writes a pair as the policy document spells it. func (p Pair) MarshalJSON() ([]byte, error) { + if p.limitPresent { + return json.Marshal(struct { + Model string `json:"model"` + ReasoningEffort string `json:"reasoningEffort"` + AutoCompactTokenLimit any `json:"autoCompactTokenLimit"` + }{p.Model, p.Effort, p.limit}) + } return json.Marshal(pairWire{Model: p.Model, ReasoningEffort: p.Effort, AutoCompactTokenLimit: p.AutoCompactTokenLimit}) } diff --git a/plugins/crw/.codex-plugin/plugin.json b/plugins/crw/.codex-plugin/plugin.json index bf78307d4e..0145df6f48 100644 --- a/plugins/crw/.codex-plugin/plugin.json +++ b/plugins/crw/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "crw", - "version": "0.4.0+0af82bb3897e", + "version": "0.4.0+edbdce6d07e3", "description": "Linear-driven workflow skills for Codex: define an initiative, plan projects and issues, run delivery, and check the result against its canonical criteria.", "author": { "name": "thisisjun786", diff --git a/plugins/crw/skills/crw-run/references/bridge.md b/plugins/crw/skills/crw-run/references/bridge.md index 2ff0efeab3..9a6dcf8456 100644 --- a/plugins/crw/skills/crw-run/references/bridge.md +++ b/plugins/crw/skills/crw-run/references/bridge.md @@ -242,6 +242,11 @@ on a relay resume too, where the value is resolved from the policy by the pair t states and the receipt is the only place the send can say what it carried. A resume that sent no limit records no such observation. +The value applies where the host takes the configuration: at creation, or when the host loads a +thread it did not have. A resume to a thread the host already holds answers with that thread's +current state, so it does not install a new threshold; the receipt saying the limit was sent is not +the same as the thread now compacting under it. + **Write 550000.** The probe measured the mechanism, not this number: at `config.model_auto_compact_token_limit=4000` the host compacted, and 550000 is the value recommended for `inferhub/deepseek-v4.1-flash` because it sits about 110,000 below the highest From 2e9aa3d0f7de58987c227e2c2e4cddf37cf10cbf Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:17:33 +0900 Subject: [PATCH 8/9] CRW-852: carry the pair's limit on the manage child-resume path too crw manage child-resume resumes a recorded child from the relay's settings-show answer, and that record cannot hold the auto-compaction limit (the host never reports it back). The command therefore reloaded a capped child with the host's own threshold and reported success, which is the failure the limit exists to prevent. The resume now resolves the limit from the execution policy by the pair the record states, using the role the relay's gate confirmed the task is bound to (falling back to the record's own citation), and puts it in the thread/resume config. A record that cites an exception, a role the policy does not declare, or a pair that declares no limit sends no key, exactly as the relay's own resume does. A configured policy that cannot be read is a refusal rather than a silent omission. Both documents now say which read wins: the bound role the gate confirmed, with the record's citation as the fallback. --- docs/role-execution-policy.md | 10 ++- internal/manage/autocompact_resume_test.go | 79 +++++++++++++++++ internal/manage/childresume.go | 84 +++++++++++++++++-- plugins/crw/.codex-plugin/plugin.json | 2 +- .../crw/skills/crw-run/references/bridge.md | 5 +- 5 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 internal/manage/autocompact_resume_test.go diff --git a/docs/role-execution-policy.md b/docs/role-execution-policy.md index 16f28d460a..7b335913d8 100644 --- a/docs/role-execution-policy.md +++ b/docs/role-execution-policy.md @@ -159,10 +159,12 @@ not load, and so does an entry that puts it beside `pairs` instead of inside one The relay reads the same declaration. A task's recorded settings come from the host's creation response, which never carries this value, so a relay delivery or correction send resolves the -recipient's role from its record and the limit from the pair that record states, and adds it to the -resume's config — including on a settings-free resume, which transmits no pair but may still carry -the limit of the pair the record says the thread is on. A record on a pair that declares no limit -sends none rather than inheriting a sibling pair's, and a record citing an exception sends none. +recipient's role and the limit from the pair that role states, and adds it to the resume's config — +including on a settings-free resume, which transmits no pair but may still carry the limit of the +pair the thread is on. The role is the one the relay's own gate confirmed the task is bound to (a +record need not cite its own role), falling back to the record's own citation when the task is +bound to none. A pair that declares no limit sends none rather than inheriting a sibling pair's, +and a record citing an exception sends none. A value that is not a positive integer is refused when the file is read, through the same `execution_policy_unreadable` path as every other unusable policy value: a string, a boolean, a diff --git a/internal/manage/autocompact_resume_test.go b/internal/manage/autocompact_resume_test.go new file mode 100644 index 0000000000..180c8bc8c5 --- /dev/null +++ b/internal/manage/autocompact_resume_test.go @@ -0,0 +1,79 @@ +package manage + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" +) + +// autoCompactResumePolicy is the child pair child-resume must read the limit from. The command +// resumes a child the relay recorded, and the record cannot hold this value (the host never reports +// it back), so the limit is resolved from the policy by the pair the record states. +func autoCompactResumePolicy(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "execution-policy.json") + body := "{\"roles\": {\"child\": {\"model\": \"m\", \"reasoningEffort\": \"xhigh\", \"autoCompactTokenLimit\": 550000}}}" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// A child the relay recorded is resumed by this command too, and it is the same child whose +// provider cuts the stream above its window. The resume it builds has to carry the pair's limit, +// or reloading a capped child through this command would quietly drop the protection and report +// success without recording that the limit went out. +func TestResumeCarriesTheRecordedPairsAutoCompactLimit(t *testing.T) { + host := resumeHost(t, "notLoaded") + // settings-show answers the role the record cites beside the settings; the limit is resolved + // from that role's pair. + exe, _ := resumeRelayScript(t, resumeTestAssignment, autoCompactResumeSettings, 0) + e, _, _ := resumeEnv(t, exe) + t.Setenv(execution.EnvPolicy, autoCompactResumePolicy(t)) + cfg := resumeConfig(host, "alpha") + cfg.Bridge.ExecutionPolicy = os.Getenv(execution.EnvPolicy) + if _, err := resumeRun(context.Background(), e, cfg, resumeOptions{relationship: "rel-1", message: "m"}); err != nil { + t.Fatal(err) + } + var resume map[string]any + if err := json.Unmarshal(resumeHostRequests(host)[1].Params, &resume); err != nil { + t.Fatal(err) + } + config, _ := resume["config"].(map[string]any) + if config["model_auto_compact_token_limit"] != float64(550000) { + t.Fatalf("the resume config does not carry the pair's limit: %v", config) + } +} + +// autoCompactResumeSettings is resumeTestSettings with the role the record cites, which is what the +// relay's settings-show answer carries beside the settings. +const autoCompactResumeSettings = `{"task":"01child","usable":true,"citedRole":"child","settings":{"sandbox":{"type":"readOnly","networkAccess":false},"approvalPolicy":"never","cwd":"/w","runtimeWorkspaceRoots":["/w"],"model":"m","reasoningEffort":"xhigh"}}` + +// A pair that declares no limit adds no key: the command behaves exactly as it did before. +func TestResumeWithoutAPolicyLimitSendsNoKey(t *testing.T) { + host := resumeHost(t, "notLoaded") + exe, _ := resumeRelayScript(t, resumeTestAssignment, resumeTestSettings, 0) + e, _, _ := resumeEnv(t, exe) + path := filepath.Join(t.TempDir(), "execution-policy.json") + if err := os.WriteFile(path, []byte("{\"roles\": {\"child\": {\"model\": \"m\", \"reasoningEffort\": \"xhigh\"}}}"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(execution.EnvPolicy, path) + cfg := resumeConfig(host, "alpha") + cfg.Bridge.ExecutionPolicy = path + if _, err := resumeRun(context.Background(), e, cfg, resumeOptions{relationship: "rel-1", message: "m"}); err != nil { + t.Fatal(err) + } + var resume map[string]any + if err := json.Unmarshal(resumeHostRequests(host)[1].Params, &resume); err != nil { + t.Fatal(err) + } + config, _ := resume["config"].(map[string]any) + if _, present := config["model_auto_compact_token_limit"]; present { + t.Fatalf("a pair without a limit sent one: %v", config) + } +} diff --git a/internal/manage/childresume.go b/internal/manage/childresume.go index dda7b24227..e671e0f502 100644 --- a/internal/manage/childresume.go +++ b/internal/manage/childresume.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/thisisjun786/codex-relay-workflow/internal/bridge/appserver" + "github.com/thisisjun786/codex-relay-workflow/internal/bridge/execution" "github.com/thisisjun786/codex-relay-workflow/internal/quote" ) @@ -64,6 +65,14 @@ type resumeSettings struct { ReasoningEffort string CWD string ApprovalPolicy string + // CitedRole and CitedException are what the record cites, and BoundRole is the role the relay's + // gate checked the record against. The auto-compaction limit belongs to a pair rather than to the + // record, so it is resolved from the policy by the pair the record states, and the role to read + // that pair from is the bound one the gate authorized against, falling back to the record's own + // citation when the relay named no binding. + CitedRole string + CitedException *string + BoundRole string // RuntimeWorkspaceRoots is a pointer so an empty list is a value: the relay preserves the // recorded [] and the resume must send it as an empty array, while a key that is absent or // null supplied nothing and stays missing. @@ -159,10 +168,13 @@ func resumeRecorded(ctx context.Context, e *Env, cfg *Config, child string) (*re return nil, &resumeFailure{Reason: resumeSettingsUnavailable, Detail: "the relay exited with status " + fmt.Sprint(code) + ": " + resumeTrim(stdout)} } var answer struct { - Settings resumeSettings `json:"settings"` - Usable *bool `json:"usable"` - Deliverable *bool `json:"deliverable"` - RecordFinding *struct { + Settings resumeSettings `json:"settings"` + Usable *bool `json:"usable"` + Deliverable *bool `json:"deliverable"` + CitedRole *string `json:"citedRole"` + BoundRole *string `json:"boundRole"` + CitedException *string `json:"citedException"` + RecordFinding *struct { Code, Detail string } `json:"recordFinding"` RoleFinding *struct { @@ -203,12 +215,64 @@ func resumeRecorded(ctx context.Context, e *Env, cfg *Config, child string) (*re if len(missing) > 0 { return nil, &resumeFailure{Reason: resumeSettingsUnavailable, Detail: "the recorded settings are missing " + strings.Join(missing, ", ")} } + if answer.CitedRole != nil { + settings.CitedRole = *answer.CitedRole + } + if answer.BoundRole != nil { + settings.BoundRole = *answer.BoundRole + } + if answer.CitedException != nil { + settings.CitedException = answer.CitedException + } return &settings, nil } // resumeSandboxMode is the thread/resume mode the recorded sandbox takes. A type this command // cannot name is settings_unavailable, because resuming with a mode the record does not carry is // what the issue refuses. +// +// resumeAutoCompactLimit is the pair's optional model_auto_compact_token_limit for this child, nil +// when it has none. The host never reports the value back, so the relay record cannot hold it and a +// resume built from that record alone would drop it; it is resolved from the execution policy by the +// pair the record states, exactly as the relay's own delivery resume does. The role is the one the +// relay's gate confirmed the task is bound to, falling back to the record's own citation. A record +// that cites an exception names no pair and sends none. A configured policy that cannot be read is a +// refusal rather than a silent omission: resuming a capped child without its limit is the failure +// this command exists to prevent, and an unreadable policy means the limit cannot be established. +func resumeAutoCompactLimit(cfg *Config, settings resumeSettings) (*int64, error) { + if settings.CitedException != nil { + return nil, nil + } + role := settings.BoundRole + if role == "" { + role = settings.CitedRole + } + if role == "" { + return nil, nil + } + path := strings.TrimSpace(cfg.Bridge.ExecutionPolicy) + if path == "" { + return nil, nil + } + raw, err := execution.ReadFile(path) + if err != nil { + return nil, &resumeFailure{Reason: resumeSettingsUnavailable, Detail: "the execution policy " + quote.Value(path) + " could not be read to resolve the pair's auto-compaction limit: " + err.Error() + "; nothing was sent"} + } + policy, err := execution.FromBytes(raw, path) + if err != nil { + return nil, &resumeFailure{Reason: resumeSettingsUnavailable, Detail: "the execution policy " + quote.Value(path) + " could not be read to resolve the pair's auto-compaction limit: " + err.Error() + "; nothing was sent"} + } + declared, ok := policy.Role(role) + if !ok { + return nil, nil + } + for _, pair := range declared.Pairs { + if pair.Model == settings.Model && pair.Effort == settings.ReasoningEffort { + return pair.AutoCompactTokenLimit, nil + } + } + return nil, nil +} func resumeSandboxMode(sandbox map[string]any) (string, error) { kind, _ := sandbox["type"].(string) mode := resumeSettingsModes[kind] @@ -271,6 +335,10 @@ func resumeRun(ctx context.Context, e *Env, cfg *Config, opts resumeOptions) (*r if err != nil { return nil, err } + limit, err := resumeAutoCompactLimit(cfg, *settings) + if err != nil { + return nil, err + } report := &resumeReport{OK: true, Thread: child, Generation: generation, Model: settings.Model, Effort: settings.ReasoningEffort, Servers: map[string]string{}, DryRun: opts.dryRun} client := appserver.New(cfg.Relay.Socket, appserver.DefaultBounds) @@ -346,12 +414,16 @@ func resumeRun(ctx context.Context, e *Env, cfg *Config, opts resumeOptions) (*r } // 2. thread/resume with the recorded settings and the servers switched off. subscribed = true + config := resumeSandboxConfig(map[string]any{"model_reasoning_effort": settings.ReasoningEffort, + "mcp_servers": resumeMCPConfig(disabled)}, settings.Sandbox) + if limit != nil { + config["model_auto_compact_token_limit"] = *limit + } resumed, err := call("thread/resume", map[string]any{ "threadId": child, "excludeTurns": true, "model": settings.Model, "cwd": settings.CWD, "sandbox": mode, "approvalPolicy": settings.ApprovalPolicy, "runtimeWorkspaceRoots": settings.RuntimeWorkspaceRoots, - "config": resumeSandboxConfig(map[string]any{"model_reasoning_effort": settings.ReasoningEffort, - "mcp_servers": resumeMCPConfig(disabled)}, settings.Sandbox), + "config": config, }) if err != nil { return nil, err diff --git a/plugins/crw/.codex-plugin/plugin.json b/plugins/crw/.codex-plugin/plugin.json index 0145df6f48..5c20f444e4 100644 --- a/plugins/crw/.codex-plugin/plugin.json +++ b/plugins/crw/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "crw", - "version": "0.4.0+edbdce6d07e3", + "version": "0.4.0+8719c4fe2285", "description": "Linear-driven workflow skills for Codex: define an initiative, plan projects and issues, run delivery, and check the result against its canonical criteria.", "author": { "name": "thisisjun786", diff --git a/plugins/crw/skills/crw-run/references/bridge.md b/plugins/crw/skills/crw-run/references/bridge.md index 9a6dcf8456..c50a005fee 100644 --- a/plugins/crw/skills/crw-run/references/bridge.md +++ b/plugins/crw/skills/crw-run/references/bridge.md @@ -239,8 +239,9 @@ writes it on the role's pair in the execution policy, and a file without it is u does not report the value back, so the resume observation never compares it, its silence withholds nothing, and the receipt lists it under `requested` and `unobservable` rather than `verified` -- on a relay resume too, where the value is resolved from the policy by the pair the task's record -states and the receipt is the only place the send can say what it carried. A resume that sent no -limit records no such observation. +states — the role is the one the relay's gate confirmed the task is bound to, falling back to the +record's own citation — and the receipt is the only place the send can say what it carried. A +resume that sent no limit records no such observation. The value applies where the host takes the configuration: at creation, or when the host loads a thread it did not have. A resume to a thread the host already holds answers with that thread's From df713203e1d8bee5b1265e45b977f169a9bede2a Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:40:20 +0900 Subject: [PATCH 9/9] CRW-852: resolve child-resume's limit from the policy the gate read crw manage child-resume resolved the pair's auto-compaction limit from manage.bridge.execution_policy, but the gate that authorized the record is the settings-show subprocess, which reads CODEX_THREAD_BRIDGE_EXECUTION_POLICY from the environment it inherits. Under the supported environment-only configuration the gate accepted a capped pair and the command then sent no limit, so a capped child was reloaded under the host's own threshold while the command reported success. The environment value is now authoritative, with the configuration value as the fallback. The report also says what went out: the value the resume sent, and that the host cannot be asked about it. The host never reports the limit back, so without that an operator cannot tell a capped reload from an uncapped one. The execution-policy guide no longer tells operators that a pairs entry must be exactly {model, reasoningEffort}, which the new field had made wrong. --- docs/role-execution-policy.md | 7 ++-- internal/manage/autocompact_resume_test.go | 45 ++++++++++++++++++++++ internal/manage/childresume.go | 26 +++++++++++-- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/docs/role-execution-policy.md b/docs/role-execution-policy.md index 7b335913d8..02d7a5e1ce 100644 --- a/docs/role-execution-policy.md +++ b/docs/role-execution-policy.md @@ -104,9 +104,10 @@ with the model beside it: Sonnet at SOL's effort is refused too, because an effo model. The list keeps the order the file declares, and that order carries no preference. The file is refused when it is read, not at the first creation, if a `pairs` list is empty or is not a -list, names the same pair twice, holds an entry that is not exactly `{model, reasoningEffort}`, sits in -the same entry as `model` or `reasoningEffort`, is declared for the `supervisor`, or names a pair that the -file's `allowed` list does not approve. A list of one pair is the one-pair form written another way. +list, names the same pair twice, holds an entry that states anything beyond `model`, `reasoningEffort` +and the optional `autoCompactTokenLimit` (see "A pair may cap auto-compaction"), sits in the same entry +as `model` or `reasoningEffort`, is declared for the `supervisor`, or names a pair that the file's +`allowed` list does not approve. A list of one pair is the one-pair form written another way. The description of a policy whose roles list several pairs must also fit what the relay publishes about it (half of the 64 KiB its readers accept), so an absurdly long list is refused when the file is read instead of leaving every worker check reading "unreadable". diff --git a/internal/manage/autocompact_resume_test.go b/internal/manage/autocompact_resume_test.go index 180c8bc8c5..e09f9fc546 100644 --- a/internal/manage/autocompact_resume_test.go +++ b/internal/manage/autocompact_resume_test.go @@ -77,3 +77,48 @@ func TestResumeWithoutAPolicyLimitSendsNoKey(t *testing.T) { t.Fatalf("a pair without a limit sent one: %v", config) } } + +// The gate that authorized the record read the policy the settings-show subprocess inherited from +// the environment, so that is the policy this command has to resolve the limit from. Reading only +// manage.bridge.execution_policy would find nothing under the supported environment-only +// configuration, while the gate had accepted the capped pair. +func TestResumeResolvesTheLimitFromTheGatePolicyEnvironment(t *testing.T) { + host := resumeHost(t, "notLoaded") + exe, _ := resumeRelayScript(t, resumeTestAssignment, autoCompactResumeSettings, 0) + e, _, _ := resumeEnv(t, exe) + t.Setenv(execution.EnvPolicy, autoCompactResumePolicy(t)) + cfg := resumeConfig(host, "alpha") + cfg.Bridge.ExecutionPolicy = "" + if _, err := resumeRun(context.Background(), e, cfg, resumeOptions{relationship: "rel-1", message: "m"}); err != nil { + t.Fatal(err) + } + var resume map[string]any + if err := json.Unmarshal(resumeHostRequests(host)[1].Params, &resume); err != nil { + t.Fatal(err) + } + config, _ := resume["config"].(map[string]any) + if config["model_auto_compact_token_limit"] != float64(550000) { + t.Fatalf("the resume config does not carry the pair's limit: %v", config) + } +} + +// The host never reports the value back, so the report has to say what went out and that the host +// cannot confirm it: an operator reading a bare ok cannot tell a capped reload from an uncapped one. +func TestResumeReportsTheLimitItSentAsUnobservable(t *testing.T) { + host := resumeHost(t, "notLoaded") + exe, _ := resumeRelayScript(t, resumeTestAssignment, autoCompactResumeSettings, 0) + e, _, _ := resumeEnv(t, exe) + t.Setenv(execution.EnvPolicy, autoCompactResumePolicy(t)) + cfg := resumeConfig(host, "alpha") + cfg.Bridge.ExecutionPolicy = os.Getenv(execution.EnvPolicy) + report, err := resumeRun(context.Background(), e, cfg, resumeOptions{relationship: "rel-1", message: "m"}) + if err != nil { + t.Fatal(err) + } + if report.AutoCompactTokenLimit == nil || *report.AutoCompactTokenLimit != 550000 { + t.Fatalf("the report does not carry the limit it sent: %+v", report) + } + if !report.AutoCompactUnobservable { + t.Fatalf("the report does not mark the limit unobservable: %+v", report) + } +} diff --git a/internal/manage/childresume.go b/internal/manage/childresume.go index e671e0f502..120b4b884e 100644 --- a/internal/manage/childresume.go +++ b/internal/manage/childresume.go @@ -92,6 +92,13 @@ type resumeReport struct { Servers map[string]string `json:"servers"` AdmitTurn string `json:"admitTurn,omitempty"` DryRun bool `json:"dryRun,omitempty"` + // AutoCompactTokenLimit is the value this resume sent, nil when the pair declares none. The + // host never reports it back, so the report states it the way the bridge states a setting the + // host cannot confirm: the value that went out, and AutoCompactUnobservable saying the host + // cannot be asked about it. Without these a reader cannot tell a capped reload from an + // uncapped one. + AutoCompactTokenLimit *int64 `json:"autoCompactTokenLimit,omitempty"` + AutoCompactUnobservable bool `json:"autoCompactUnobservable,omitempty"` } // resumeFailure is a refusal: the reason and, where the host or the relay gave one, its detail. @@ -239,7 +246,12 @@ func resumeRecorded(ctx context.Context, e *Env, cfg *Config, child string) (*re // that cites an exception names no pair and sends none. A configured policy that cannot be read is a // refusal rather than a silent omission: resuming a capped child without its limit is the failure // this command exists to prevent, and an unreadable policy means the limit cannot be established. -func resumeAutoCompactLimit(cfg *Config, settings resumeSettings) (*int64, error) { +// +// The policy is the one the gate read. The gate is the settings-show subprocess, which reads +// CODEX_THREAD_BRIDGE_EXECUTION_POLICY from the environment it inherits, so that environment value +// is authoritative here and manage.bridge.execution_policy is the fallback for a configuration that +// carries the policy for other commands but leaves the environment unset. +func resumeAutoCompactLimit(e *Env, cfg *Config, settings resumeSettings) (*int64, error) { if settings.CitedException != nil { return nil, nil } @@ -250,7 +262,10 @@ func resumeAutoCompactLimit(cfg *Config, settings resumeSettings) (*int64, error if role == "" { return nil, nil } - path := strings.TrimSpace(cfg.Bridge.ExecutionPolicy) + path := strings.TrimSpace(e.Getenv(execution.EnvPolicy)) + if path == "" { + path = strings.TrimSpace(cfg.Bridge.ExecutionPolicy) + } if path == "" { return nil, nil } @@ -335,12 +350,17 @@ func resumeRun(ctx context.Context, e *Env, cfg *Config, opts resumeOptions) (*r if err != nil { return nil, err } - limit, err := resumeAutoCompactLimit(cfg, *settings) + limit, err := resumeAutoCompactLimit(e, cfg, *settings) if err != nil { return nil, err } report := &resumeReport{OK: true, Thread: child, Generation: generation, Model: settings.Model, Effort: settings.ReasoningEffort, Servers: map[string]string{}, DryRun: opts.dryRun} + if limit != nil { + // The value is not in the host's answer, so the report carries it rather than reading it back. + report.AutoCompactTokenLimit = limit + report.AutoCompactUnobservable = true + } client := appserver.New(cfg.Relay.Socket, appserver.DefaultBounds) defer client.Close() if err := client.Connect(ctx); err != nil {