Skip to content

Commit 8b957ff

Browse files
authored
Merge pull request #957 from sortie-ai/fix/956-ci-failure-watch-window-ceiling
fix(config): bound reactions.ci_failure.watch_window_ms
2 parents 7924c4c + a473ef4 commit 8b957ff

17 files changed

Lines changed: 352 additions & 41 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- `reactions.ci_failure.watch_window_ms` now rejects a value above `9223372036854` (about 292 years) instead of converting it to a window of a fraction of a millisecond or to no bound at all. `0` still means no time limit. A deployment currently carrying a larger value is refused at startup and by `sortie validate` until the value is lowered.
13+
([#956](https://github.com/sortie-ai/sortie/issues/956))
14+
1015
### Changed
1116

1217
- `reactions.review_comments`, `reactions.bot_review`, `reactions.merge_conflicts`, and `reactions.auto_merge` now bound a pending entry's age with a per-reaction `watch_window_ms` key instead of a hardcoded thirty-minute constant. The default stays `1800000` (thirty minutes), so a deployment that sets nothing behaves exactly as before; setting `0` removes the bound entirely. A workflow with no `auto_merge`, where a person reviews and merges, will normally want a larger value than the default. The four expiry log records changed their message text and renamed their `ttl_ms` attribute to `window_ms`.

cmd/sortie/validate_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3401,6 +3401,103 @@ func TestValidateReviewAndMergeConflictReactionConfigs(t *testing.T) {
34013401
}
34023402
}
34033403

3404+
// TestValidateWatchWindowMSAcrossKinds covers every reaction kind that
3405+
// carries watch_window_ms with a value above the shared ceiling. The
3406+
// ci_failure kind is rejected during config.NewServiceConfig and surfaces
3407+
// under a "config."-prefixed check produced by mapManagerError, while the
3408+
// other four kinds pass config construction and are rejected by
3409+
// ValidateReactionConfigs under a bare "reactions.<kind>" check, so each
3410+
// row needs its own single-fault fixture: a ci_failure rejection would
3411+
// otherwise fail NewServiceConfig and suppress every later diagnostic.
3412+
func TestValidateWatchWindowMSAcrossKinds(t *testing.T) {
3413+
t.Parallel()
3414+
3415+
const overCeiling = 9223372036855
3416+
3417+
tests := []struct {
3418+
name string
3419+
extraYAML string
3420+
wantCheck string
3421+
}{
3422+
{
3423+
name: "ci_failure",
3424+
extraYAML: `reactions:
3425+
ci_failure:
3426+
provider: github-actions
3427+
watch_window_ms: 9223372036855
3428+
`,
3429+
wantCheck: "config.reactions.ci_failure.watch_window_ms",
3430+
},
3431+
{
3432+
name: "review_comments",
3433+
extraYAML: `reactions:
3434+
review_comments:
3435+
provider: gitea
3436+
watch_window_ms: 9223372036855
3437+
`,
3438+
wantCheck: "reactions.review_comments",
3439+
},
3440+
{
3441+
name: "bot_review",
3442+
extraYAML: `reactions:
3443+
bot_review:
3444+
provider: gitea
3445+
watch_window_ms: 9223372036855
3446+
`,
3447+
wantCheck: "reactions.bot_review",
3448+
},
3449+
{
3450+
name: "merge_conflicts",
3451+
extraYAML: `reactions:
3452+
merge_conflicts:
3453+
provider: gitea
3454+
watch_window_ms: 9223372036855
3455+
`,
3456+
wantCheck: "reactions.merge_conflicts",
3457+
},
3458+
{
3459+
name: "auto_merge",
3460+
extraYAML: `reactions:
3461+
auto_merge:
3462+
provider: gitea
3463+
watch_window_ms: 9223372036855
3464+
`,
3465+
wantCheck: "reactions.auto_merge",
3466+
},
3467+
}
3468+
3469+
for _, tt := range tests {
3470+
t.Run(tt.name, func(t *testing.T) {
3471+
t.Parallel()
3472+
3473+
dir := t.TempDir()
3474+
wfPath := writeCustomWorkflowFile(t, dir, forgeFaultWorkflow(tt.extraYAML))
3475+
3476+
var stdout, stderr bytes.Buffer
3477+
code := run(context.Background(), []string{"validate", "--format", "json", wfPath}, &stdout, &stderr)
3478+
if code != 1 {
3479+
t.Fatalf("run(validate --format json) = %d, want 1; stderr: %s", code, stderr.String())
3480+
}
3481+
3482+
var out validateOutput
3483+
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
3484+
t.Fatalf("json.Unmarshal(%q) error: %v", stdout.String(), err)
3485+
}
3486+
if out.Valid {
3487+
t.Errorf("validateOutput.Valid = true, want false")
3488+
}
3489+
3490+
d := diagWithCheck(out.Errors, tt.wantCheck)
3491+
if d == nil {
3492+
t.Fatalf("validateOutput.Errors = %v, want a diagnostic with check %q", out.Errors, tt.wantCheck)
3493+
}
3494+
if want := fmt.Sprintf("%d", overCeiling); !strings.Contains(d.Message, want) {
3495+
t.Errorf("diagnostic message = %q, want offending value %q", d.Message, want)
3496+
}
3497+
})
3498+
}
3499+
}
3500+
34043501
// TestValidateGiteaForge exercises the fold point end-to-end through
34053502
// runValidate for a tracker.kind: gitea workflow, varying the reactions
34063503
// and ci_feedback blocks to isolate one forge fault per case.

docs/architecture/05-workflow-specification.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,8 @@ Extra fields:
393393

394394
- `max_log_lines` (integer, via Extra): maximum CI log tail lines. Default: `50`.
395395
- `watch_window_ms` (integer, via Extra): bounds a pending CI entry's age, measured from the last
396-
recorded head. Default: `86400000` (twenty-four hours). Must be non-negative. `0` removes the
397-
clock bound.
396+
recorded head. Default: `86400000` (twenty-four hours). MUST be non-negative and MUST NOT
397+
exceed `9223372036854`. `0` removes the clock bound.
398398

399399
**Reaction kind: `review_comments`**
400400

docs/architecture/06-configuration-specification.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,8 @@ This section is intentionally redundant so a coding agent can implement the conf
278278
`reactions.review_comments.provider`, ignores whether its `escalation` value is `label` or
279279
`comment`, and never falls through to another reaction kind's label. The primary path always
280280
parks by label; it borrows only this label name and no other review-reaction behavior
281+
- `reactions.ci_failure.watch_window_ms`: integer, default `86400000` (24 h); non-negative, not
282+
above `9223372036854`; `0` removes the bound; re-read on every tick
281283
- `reactions.review_comments.poll_interval_ms`: integer, default `120000` (2 min); minimum `30000`
282284
- `reactions.review_comments.debounce_ms`: integer, default `60000` (60 sec); non-negative
283285
- `reactions.review_comments.max_continuation_turns`: integer, default `3`; positive

docs/architecture/12-ci-feedback-contract.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,8 @@ the entry's lifetime.
302302
**Age and the watch window.** The entry's age is measured from `HeadRecordedAt`, the UTC time this
303303
process last recorded a head, falling back to the entry's creation time before any head has been
304304
recorded. `reactions.ci_failure.watch_window_ms` bounds that age (default `86400000`, twenty-four
305-
hours; `0` removes the clock bound). Past the window, the entry and its attempt counter are dropped
305+
hours; `0` removes the clock bound; a value above `9223372036854` is rejected when the typed
306+
configuration is built). Past the window, the entry and its attempt counter are dropped
306307
and a warning is logged; the fingerprint row is left intact. The watch also ends, whatever the clock
307308
says, on merge, on close, on a missing pull request (`ErrSCMNotFound`), and when the tracker issue
308309
reaches a `tracker.terminal_states` state.

docs/architecture/22-test-and-validation-matrix.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ Unless otherwise noted, Sections 17.1 through 17.7 are `Core Conformance`. Bulle
3434
- An absent `agent.max_consecutive_absences` key takes the default of `3`; `0` and negative
3535
values are rejected at config parse time; `SORTIE_AGENT_MAX_CONSECUTIVE_ABSENCES` overrides a
3636
file-supplied value and is rejected under the same rule
37+
- An absent `reactions.ci_failure.watch_window_ms` key takes the default; a negative value and a
38+
value above `9223372036854` are rejected at config parse time, naming the field and the value;
39+
`9223372036854` itself is accepted
3740
- Per-state concurrency override map normalizes state names and ignores invalid values
3841
- Prompt template renders `issue`, `attempt`, and `run`
3942
- Prompt rendering fails on unknown variables (strict mode)

docs/workflow-reference.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -986,7 +986,7 @@ Additional fields (via Extra):
986986
| Field | Type | Default | Dynamic Reload | Description |
987987
| ----------------- | ------- | ------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------ |
988988
| `max_log_lines` | integer | `50` | Requires restart | Maximum CI log tail lines for prompt injection. `0` disables. Must be non-negative. |
989-
| `watch_window_ms` | integer | `86400000` | Every tick | Bounds a pending entry's age, measured from the last recorded head. `0` removes the clock bound. Must be non-negative. |
989+
| `watch_window_ms` | integer | `86400000` | Every tick | Bounds a pending entry's age, measured from the last recorded head. `0` removes the clock bound. Must be non-negative and must not exceed `9223372036854`. |
990990

991991
Example:
992992

@@ -1294,8 +1294,7 @@ merged-or-closed drop branch, so setting `watch_window_ms: 0` leaves a `review_c
12941294
until the tracker issue reaches a terminal state, and leaves an `auto_merge` entry polling
12951295
until its own merge attempt returns the already-merged disposition. A quoted numeric value
12961296
(for example `"1800000"`) is rejected for these four keys, although
1297-
`reactions.ci_failure.watch_window_ms` accepts one. These four keys also reject a value
1298-
above `9223372036854`, where `reactions.ci_failure.watch_window_ms` does not.
1297+
`reactions.ci_failure.watch_window_ms` accepts one.
12991298

13001299
#### Reaction kind: `label_commands`
13011300

@@ -1503,6 +1502,7 @@ reactions:
15031502
configuration error.
15041503
- `max_retries` must be non-negative for all kinds.
15051504
- `escalation` must be `"label"` or `"comment"` for all kinds.
1505+
- `watch_window_ms` must be non-negative and must not exceed `9223372036854` for `ci_failure`, `review_comments`, `bot_review`, `merge_conflicts`, and `auto_merge`.
15061506
- `poll_interval_ms` must be >= `30000` for `review_comments`.
15071507
- `debounce_ms` must be non-negative for `review_comments`.
15081508
- `max_continuation_turns` must be positive for `review_comments`.
@@ -1523,8 +1523,9 @@ reactions:
15231523
- When `provider` is absent or empty, all other fields in the kind sub-object are ignored.
15241524

15251525
**Where each rule is enforced:** the rules that the config layer owns (reaction key shape,
1526-
`max_retries`, `escalation`, `escalation_label`, and every `label_commands` rule) run during
1527-
typed config construction, so `sortie validate` reports them offline. For the kind-specific
1526+
`max_retries`, `escalation`, `escalation_label`, every `label_commands` rule, and
1527+
`reactions.ci_failure`'s Extra keys) run during typed config construction, so `sortie validate`
1528+
reports them offline. For the kind-specific
15281529
rules, `sortie validate` runs the `review_comments`, `auto_merge`, `bot_review`,
15291530
`merge_conflicts`, and `merge_completion` builders. These are the same builders used when the
15301531
orchestrator constructs the reactions at startup, so both paths report the same invalid values.
@@ -3551,6 +3552,8 @@ Each error identifies the offending field path.
35513552
| `config: ci_feedback.max_log_lines: invalid integer value: <val>` | Non-integer value for `max_log_lines`. | Use a plain integer (e.g., `50`). |
35523553
| `config: ci_feedback.max_log_lines: must be non-negative` | Negative value for `max_log_lines`. | Use `0` (disable log fetching) or a positive integer. |
35533554
| `config: ci_feedback.escalation: must be "label" or "comment", got "<val>"` | Invalid escalation strategy. | Use `"label"` or `"comment"`. |
3555+
| `config: reactions.ci_failure.watch_window_ms: must not exceed 9223372036854 (about 292 years); use 0 for no time limit, got <val>` | `watch_window_ms` exceeds the ceiling. | Lower the value, or use `0` for no time limit. |
3556+
| `config: reactions.ci_failure.watch_window_ms: must be non-negative, got <val>` | Negative value for `watch_window_ms`. | Use `0` (no time limit) or a positive integer. |
35543557

35553558
### 9.3 Environment Variable Errors
35563559

@@ -3636,6 +3639,7 @@ lists the `SORTIE_*` variable that overrides the field, or "—" if not overrida
36363639
| `reactions.<kind>.escalation` | string | `label` || `"label"` or `"comment"`; restart required except for `ci_failure` |
36373640
| `reactions.<kind>.escalation_label` | string | `needs-human` || Applied when `escalation` is `"label"`; restart required except for `ci_failure` |
36383641
| `reactions.ci_failure.max_log_lines` | integer | `50` || CI log tail lines; `0` disables; non-negative; restart required |
3642+
| `reactions.<kind>.watch_window_ms` | integer | `86400000` for `ci_failure`, `1800000` for the other four || Bounds a pending entry's age; non-negative; not above `9223372036854`; `0` removes the bound; restart required except for `ci_failure` |
36393643
| `reactions.review_comments.poll_interval_ms` | integer | `120000` || Review poll interval; min `30000`; restart required |
36403644
| `reactions.review_comments.debounce_ms` | integer | `60000` || Debounce after last comment; non-negative; restart required |
36413645
| `reactions.review_comments.max_continuation_turns` | integer | `3` || Review-fix turns before escalation; positive; restart required |

internal/config/config.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -958,10 +958,10 @@ func populateCIFeedbackFromReactions(rc ReactionConfig) (CIFeedbackConfig, error
958958
}
959959
watchWindowMS = parsed
960960
}
961-
if watchWindowMS < 0 {
961+
if err := ValidateWatchWindowMS(watchWindowMS); err != nil {
962962
return CIFeedbackConfig{}, &ConfigError{
963963
Field: "reactions.ci_failure.watch_window_ms",
964-
Message: "must be non-negative",
964+
Message: err.Error(),
965965
}
966966
}
967967

@@ -1442,7 +1442,8 @@ type CIFeedbackConfig struct {
14421442
// recorded head rather than from the entry's creation, so an
14431443
// actively worked pull request stays watched and only silence ages
14441444
// it out. Default 86400000 (twenty-four hours). Zero removes the
1445-
// clock bound. Must be non-negative.
1445+
// clock bound. Must be non-negative and must not exceed
1446+
// [MaxWatchWindowMS].
14461447
WatchWindowMS int
14471448
}
14481449

internal/config/config_test.go

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2128,11 +2128,12 @@ func TestPopulateCIFeedbackFromReactions(t *testing.T) {
21282128
t.Parallel()
21292129

21302130
tests := []struct {
2131-
name string
2132-
rc ReactionConfig
2133-
want CIFeedbackConfig
2134-
wantErr bool
2135-
wantField string
2131+
name string
2132+
rc ReactionConfig
2133+
want CIFeedbackConfig
2134+
wantErr bool
2135+
wantField string
2136+
wantMsgSubstr string
21362137
}{
21372138
{
21382139
name: "ProviderMapsToKind",
@@ -2252,6 +2253,55 @@ func TestPopulateCIFeedbackFromReactions(t *testing.T) {
22522253
wantErr: true,
22532254
wantField: "reactions.ci_failure.watch_window_ms",
22542255
},
2256+
{
2257+
name: "WatchWindowMSCeilingAccepted",
2258+
rc: ReactionConfig{
2259+
Provider: "github-actions",
2260+
Extra: map[string]any{"watch_window_ms": 9223372036854},
2261+
},
2262+
want: CIFeedbackConfig{
2263+
Kind: "github-actions",
2264+
MaxLogLines: 50,
2265+
WatchWindowMS: 9223372036854,
2266+
},
2267+
},
2268+
{
2269+
name: "WatchWindowMSAboveCeiling",
2270+
rc: ReactionConfig{
2271+
Provider: "github-actions",
2272+
Extra: map[string]any{"watch_window_ms": 9223372036855},
2273+
},
2274+
wantErr: true,
2275+
wantField: "reactions.ci_failure.watch_window_ms",
2276+
wantMsgSubstr: "must not exceed",
2277+
},
2278+
{
2279+
name: "WatchWindowMSWrapFixtureOne",
2280+
rc: ReactionConfig{
2281+
Provider: "github-actions",
2282+
Extra: map[string]any{"watch_window_ms": 18446744073710},
2283+
},
2284+
wantErr: true,
2285+
wantField: "reactions.ci_failure.watch_window_ms",
2286+
},
2287+
{
2288+
name: "WatchWindowMSWrapFixtureTwo",
2289+
rc: ReactionConfig{
2290+
Provider: "github-actions",
2291+
Extra: map[string]any{"watch_window_ms": 18446744073709},
2292+
},
2293+
wantErr: true,
2294+
wantField: "reactions.ci_failure.watch_window_ms",
2295+
},
2296+
{
2297+
name: "WatchWindowMSWrapFixtureThree",
2298+
rc: ReactionConfig{
2299+
Provider: "github-actions",
2300+
Extra: map[string]any{"watch_window_ms": 99999999999999},
2301+
},
2302+
wantErr: true,
2303+
wantField: "reactions.ci_failure.watch_window_ms",
2304+
},
22552305
{
22562306
name: "EscalationAndLabelPassThrough",
22572307
rc: ReactionConfig{
@@ -2279,6 +2329,12 @@ func TestPopulateCIFeedbackFromReactions(t *testing.T) {
22792329

22802330
if tt.wantErr {
22812331
assertConfigErrorField(t, err, tt.wantField)
2332+
if tt.wantMsgSubstr != "" {
2333+
var ce *ConfigError
2334+
if errors.As(err, &ce) && !strings.Contains(ce.Message, tt.wantMsgSubstr) {
2335+
t.Errorf("ConfigError.Message = %q, want substring %q", ce.Message, tt.wantMsgSubstr)
2336+
}
2337+
}
22822338
return
22832339
}
22842340
if err != nil {

internal/config/watchwindow.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"time"
7+
)
8+
9+
// MaxWatchWindowMS is the largest watch_window_ms value whose conversion to
10+
// a time.Duration stays positive.
11+
const MaxWatchWindowMS int64 = math.MaxInt64 / int64(time.Millisecond)
12+
13+
// ValidateWatchWindowMS reports why ms is not a usable watch window in
14+
// milliseconds, and nil when it is.
15+
//
16+
// ms is an already-coerced millisecond count; ValidateWatchWindowMS performs
17+
// no type coercion of its own. The returned error carries no field name or
18+
// key name, so a caller composes it with whatever identifier its own
19+
// diagnostic uses. ValidateWatchWindowMS is a pure function of its argument
20+
// and is safe for concurrent use.
21+
func ValidateWatchWindowMS(ms int) error {
22+
if ms < 0 {
23+
return fmt.Errorf("must be non-negative, got %d", ms)
24+
}
25+
if int64(ms) > MaxWatchWindowMS {
26+
return fmt.Errorf("must not exceed %d (about 292 years); use 0 for no time limit, got %d", MaxWatchWindowMS, ms)
27+
}
28+
return nil
29+
}

0 commit comments

Comments
 (0)