From 8639d82e044bcdf84287c9ceee6aa8e555047263 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:07:53 +0300 Subject: [PATCH 01/42] Add intent settle-window config - Add a configurable settle window to gate bursty intent planning. - Parse zero and valid durations while falling back on invalid input. - Cover env loading, defaults, invalid values, and zero handling in tests. --- internal/ai/config.go | 22 ++++++++++++++++++++++ internal/ai/config_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/internal/ai/config.go b/internal/ai/config.go index 4b3179a9..d96eea1f 100644 --- a/internal/ai/config.go +++ b/internal/ai/config.go @@ -49,6 +49,7 @@ const ( EnvCommitFormat = "ACD_COMMIT_FORMAT" EnvIntentWindow = "ACD_INTENT_WINDOW" EnvIntentMinPending = "ACD_INTENT_MIN_PENDING" + EnvIntentSettleWindow = "ACD_INTENT_SETTLE_WINDOW" EnvIntentMaxPendingAge = "ACD_INTENT_MAX_PENDING_AGE" EnvIntentRecentCommits = "ACD_INTENT_RECENT_COMMITS" EnvIntentDeferLimit = "ACD_INTENT_DEFER_LIMIT" @@ -62,6 +63,7 @@ const DefaultProviderTimeout = 30 * time.Second const ( DefaultIntentWindow = 10 DefaultIntentMinPending = 10 + DefaultIntentSettleWindow = 10 * time.Second DefaultIntentMaxPendingAge = 5 * time.Minute DefaultIntentRecentCommits = 5 // DefaultIntentRetryOnInvalid caps correction retries after typed @@ -148,6 +150,10 @@ type ProviderConfig struct { // intent planning pass starts. IntentMinPending int + // IntentSettleWindow is the extra burst-settle delay after the pending + // queue reaches IntentMinPending. Zero disables the settle gate. + IntentSettleWindow time.Duration + // IntentMaxPendingAge is the bounded wait escape hatch for sparse // pending queues that have not reached IntentMinPending. IntentMaxPendingAge time.Duration @@ -181,6 +187,7 @@ func LoadProviderConfigFromEnv() ProviderConfig { CommitFormat: normalizeCommitFormat(os.Getenv(EnvCommitFormat)), IntentWindow: parsePositiveIntEnv(EnvIntentWindow, DefaultIntentWindow), IntentMinPending: parsePositiveIntEnv(EnvIntentMinPending, DefaultIntentMinPending), + IntentSettleWindow: parseNonNegativeDurationEnv(EnvIntentSettleWindow, DefaultIntentSettleWindow), IntentMaxPendingAge: parsePositiveDurationEnv(EnvIntentMaxPendingAge, DefaultIntentMaxPendingAge), IntentRecentCommits: parsePositiveIntEnv(EnvIntentRecentCommits, DefaultIntentRecentCommits), IntentDeferLimit: parseNonNegativeIntEnv(EnvIntentDeferLimit, DefaultIntentDeferLimit), @@ -262,6 +269,21 @@ func parsePositiveDurationEnv(name string, fallback time.Duration) time.Duration return d } +func parseNonNegativeDurationEnv(name string, fallback time.Duration) time.Duration { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback + } + if raw == "0" { + return 0 + } + d, err := time.ParseDuration(raw) + if err != nil || d < 0 { + return fallback + } + return d +} + // normalizeMode trims whitespace, lowercases the prefix (the part before // any colon), and preserves the rest of the string (e.g. the subprocess // plugin name) verbatim. An empty string remains empty so callers can diff --git a/internal/ai/config_test.go b/internal/ai/config_test.go index 79001554..7d293df5 100644 --- a/internal/ai/config_test.go +++ b/internal/ai/config_test.go @@ -58,6 +58,7 @@ func TestLoadProviderConfigFromEnv_AllVars(t *testing.T) { t.Setenv(EnvCommitFormat, " conventional ") t.Setenv(EnvIntentWindow, "25") t.Setenv(EnvIntentMinPending, "12") + t.Setenv(EnvIntentSettleWindow, "12s") t.Setenv(EnvIntentMaxPendingAge, "90s") t.Setenv(EnvIntentRecentCommits, "8") t.Setenv(EnvIntentDeferLimit, "4") @@ -94,6 +95,9 @@ func TestLoadProviderConfigFromEnv_AllVars(t *testing.T) { if cfg.IntentMinPending != 12 { t.Fatalf("IntentMinPending=%d want 12", cfg.IntentMinPending) } + if cfg.IntentSettleWindow != 12*time.Second { + t.Fatalf("IntentSettleWindow=%v want 12s", cfg.IntentSettleWindow) + } if cfg.IntentMaxPendingAge != 90*time.Second { t.Fatalf("IntentMaxPendingAge=%v want 90s", cfg.IntentMaxPendingAge) } @@ -117,6 +121,7 @@ func TestLoadProviderConfigFromEnv_Defaults(t *testing.T) { t.Setenv(EnvCommitFormat, "") t.Setenv(EnvIntentWindow, "") t.Setenv(EnvIntentMinPending, "") + t.Setenv(EnvIntentSettleWindow, "") t.Setenv(EnvIntentMaxPendingAge, "") t.Setenv(EnvIntentRecentCommits, "") t.Setenv(EnvIntentDeferLimit, "") @@ -146,6 +151,9 @@ func TestLoadProviderConfigFromEnv_Defaults(t *testing.T) { if cfg.IntentMinPending != DefaultIntentMinPending { t.Fatalf("IntentMinPending=%d want %d", cfg.IntentMinPending, DefaultIntentMinPending) } + if cfg.IntentSettleWindow != DefaultIntentSettleWindow { + t.Fatalf("IntentSettleWindow=%v want %v", cfg.IntentSettleWindow, DefaultIntentSettleWindow) + } if cfg.IntentMaxPendingAge != DefaultIntentMaxPendingAge { t.Fatalf("IntentMaxPendingAge=%v want %v", cfg.IntentMaxPendingAge, DefaultIntentMaxPendingAge) } @@ -216,6 +224,7 @@ func TestLoadProviderConfigFromEnv_CommitFormat(t *testing.T) { func TestLoadProviderConfigFromEnv_InvalidIntentNumbersFallBack(t *testing.T) { t.Setenv(EnvIntentWindow, "0") t.Setenv(EnvIntentMinPending, "0") + t.Setenv(EnvIntentSettleWindow, "not-a-duration") t.Setenv(EnvIntentMaxPendingAge, "0") t.Setenv(EnvIntentRecentCommits, "not-a-number") t.Setenv(EnvIntentDeferLimit, "-1") @@ -227,6 +236,9 @@ func TestLoadProviderConfigFromEnv_InvalidIntentNumbersFallBack(t *testing.T) { if cfg.IntentMinPending != DefaultIntentMinPending { t.Fatalf("IntentMinPending=%d want %d", cfg.IntentMinPending, DefaultIntentMinPending) } + if cfg.IntentSettleWindow != DefaultIntentSettleWindow { + t.Fatalf("IntentSettleWindow=%v want %v", cfg.IntentSettleWindow, DefaultIntentSettleWindow) + } if cfg.IntentMaxPendingAge != DefaultIntentMaxPendingAge { t.Fatalf("IntentMaxPendingAge=%v want %v", cfg.IntentMaxPendingAge, DefaultIntentMaxPendingAge) } @@ -254,6 +266,18 @@ func TestLoadProviderConfigFromEnv_ZeroIntentDeferLimitAllowed(t *testing.T) { } } +func TestLoadProviderConfigFromEnv_ZeroIntentSettleWindowAllowed(t *testing.T) { + for _, raw := range []string{"0", "0s"} { + t.Run(raw, func(t *testing.T) { + t.Setenv(EnvIntentSettleWindow, raw) + cfg := LoadProviderConfigFromEnv() + if cfg.IntentSettleWindow != 0 { + t.Fatalf("IntentSettleWindow=%v want 0", cfg.IntentSettleWindow) + } + }) + } +} + // TestBuildProvider_DeterministicDefault: empty mode and "deterministic" // both yield DeterministicProvider with no closer. func TestBuildProvider_DeterministicDefault(t *testing.T) { From d8af41c0b4baeeb083a20dc78a06e8a23a8be7f2 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:08:06 +0300 Subject: [PATCH 02/42] Add intent settle-window plumbing - Add configurable settle delay once pending intents reach the threshold - Surface the new setting in daemon config and replay resolution - Keep negative test overrides clamped to zero/off --- internal/daemon/daemon.go | 2 ++ internal/daemon/replay.go | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 3eb1c793..51738d2a 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -363,6 +363,7 @@ func Run(ctx context.Context, opts Options) error { "commit.format": string(providerCfg.CommitFormat), "intent.window": strconv.Itoa(providerCfg.IntentWindow), "intent.min_pending": strconv.Itoa(providerCfg.IntentMinPending), + "intent.settle_window": providerCfg.IntentSettleWindow.String(), "intent.max_pending_age": providerCfg.IntentMaxPendingAge.String(), "intent.recent_commits": strconv.Itoa(providerCfg.IntentRecentCommits), "intent.defer_limit": strconv.Itoa(providerCfg.IntentDeferLimit), @@ -1712,6 +1713,7 @@ func Run(ctx context.Context, opts Options) error { CommitStrategy: providerCfg.CommitStrategy, IntentWindow: providerCfg.IntentWindow, IntentMinPending: providerCfg.IntentMinPending, + IntentSettleWindow: providerCfg.IntentSettleWindow, IntentMaxPendingAge: providerCfg.IntentMaxPendingAge, IntentRecentCommits: providerCfg.IntentRecentCommits, IntentDeferLimit: providerCfg.IntentDeferLimit, diff --git a/internal/daemon/replay.go b/internal/daemon/replay.go index 15471e19..b832f8fa 100644 --- a/internal/daemon/replay.go +++ b/internal/daemon/replay.go @@ -194,6 +194,11 @@ type ReplayOpts struct { // planning starts. Zero resolves from env. IntentMinPending int + // IntentSettleWindow is the burst-settle delay after IntentMinPending is + // reached. Zero resolves from env; use a negative value in tests to force + // zero/off. + IntentSettleWindow time.Duration + // IntentMaxPendingAge is the bounded wait escape hatch for sparse pending // queues that have not reached IntentMinPending. Zero resolves from env. IntentMaxPendingAge time.Duration @@ -794,6 +799,7 @@ type intentReplayConfig struct { planner ai.IntentPlanner window int minPending int + settleWindow time.Duration maxPendingAge time.Duration recent int deferLimit int @@ -832,6 +838,7 @@ func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), err enabled: true, window: cfg.IntentWindow, minPending: cfg.IntentMinPending, + settleWindow: cfg.IntentSettleWindow, maxPendingAge: cfg.IntentMaxPendingAge, recent: cfg.IntentRecentCommits, deferLimit: cfg.IntentDeferLimit, @@ -847,6 +854,11 @@ func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), err if opts.IntentMinPending > 0 { out.minPending = opts.IntentMinPending } + if opts.IntentSettleWindow > 0 { + out.settleWindow = opts.IntentSettleWindow + } else if opts.IntentSettleWindow < 0 { + out.settleWindow = 0 + } if opts.IntentMaxPendingAge > 0 { out.maxPendingAge = opts.IntentMaxPendingAge } @@ -864,6 +876,9 @@ func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), err if out.minPending <= 0 { out.minPending = ai.DefaultIntentMinPending } + if out.settleWindow < 0 { + out.settleWindow = 0 + } if out.maxPendingAge <= 0 { out.maxPendingAge = ai.DefaultIntentMaxPendingAge } From fdbf2de08e585dc103bfcc07a8174bd8ed88d3fe Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:08:54 +0300 Subject: [PATCH 03/42] Add settle window intent reporting - Surface the intent settle window in strategy reporting. - Parse daemon metadata for non-negative durations. - Extend status tests to cover settle window metadata and reporting. --- internal/cli/intent_observability.go | 25 +++++++++++++++++++++++-- internal/cli/status_test.go | 3 +++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/cli/intent_observability.go b/internal/cli/intent_observability.go index d7e079ae..0d7f293b 100644 --- a/internal/cli/intent_observability.go +++ b/internal/cli/intent_observability.go @@ -63,6 +63,7 @@ type intentStrategyReport struct { RecentCommits int `json:"recent_commits,omitempty"` DeferLimit int `json:"defer_limit,omitempty"` MinPending int `json:"min_pending,omitempty"` + SettleWindowSeconds int64 `json:"settle_window_seconds"` MaxPendingAgeSeconds int64 `json:"max_pending_age_seconds,omitempty"` IntentStageDiffCap int `json:"intent_stage_diff_cap,omitempty"` VisiblePendingEvents int `json:"visible_pending_events,omitempty"` @@ -160,8 +161,8 @@ func renderIntentStrategyHuman(out io.Writer, r intentStrategyReport) { status = r.Strategy } if r.Active { - fmt.Fprintf(out, "Commit strategy: %s format=%s (window %d, min pending %d, max age %s, recent commits %d, defer limit %d)\n", - status, valueOrUnset(r.CommitFormat), r.Window, r.MinPending, formatDurationCompact(time.Duration(r.MaxPendingAgeSeconds)*time.Second), r.RecentCommits, r.DeferLimit) + fmt.Fprintf(out, "Commit strategy: %s format=%s (window %d, min pending %d, settle %s, max age %s, recent commits %d, defer limit %d)\n", + status, valueOrUnset(r.CommitFormat), r.Window, r.MinPending, formatDurationCompact(time.Duration(r.SettleWindowSeconds)*time.Second), formatDurationCompact(time.Duration(r.MaxPendingAgeSeconds)*time.Second), r.RecentCommits, r.DeferLimit) } else { fmt.Fprintf(out, "Commit strategy: %s format=%s\n", status, valueOrUnset(r.CommitFormat)) } @@ -268,6 +269,9 @@ func intentStrategyFromEnv() intentStrategyReport { RecentCommits: cfg.IntentRecentCommits, DeferLimit: cfg.IntentDeferLimit, MinPending: cfg.IntentMinPending, + SettleWindowSeconds: int64( + cfg.IntentSettleWindow / time.Second, + ), MaxPendingAgeSeconds: int64( cfg.IntentMaxPendingAge / time.Second, ), @@ -311,6 +315,11 @@ func loadIntentStrategyReport(ctx context.Context, conn *sql.DB) (intentStrategy } else if ok { report.MinPending = parseIntentMetaInt(v, report.MinPending) } + if v, ok, err := metaLookup(ctx, conn, "intent.settle_window"); err != nil { + return report, fmt.Errorf("intent.settle_window: %w", err) + } else if ok { + report.SettleWindowSeconds = parseIntentMetaNonNegativeDurationSeconds(v, report.SettleWindowSeconds) + } if v, ok, err := metaLookup(ctx, conn, "intent.max_pending_age"); err != nil { return report, fmt.Errorf("intent.max_pending_age: %w", err) } else if ok { @@ -722,3 +731,15 @@ func parseIntentMetaDurationSeconds(raw string, fallback int64) int64 { } return int64(d / time.Second) } + +func parseIntentMetaNonNegativeDurationSeconds(raw string, fallback int64) int64 { + trimmed := strings.TrimSpace(raw) + if trimmed == "0" { + return 0 + } + d, err := time.ParseDuration(trimmed) + if err != nil || d < 0 { + return fallback + } + return int64(d / time.Second) +} diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go index 0a15bee5..346e77e6 100644 --- a/internal/cli/status_test.go +++ b/internal/cli/status_test.go @@ -431,6 +431,7 @@ func TestStatus_IntentStrategyUsesDaemonMetadata(t *testing.T) { "commit.strategy": "intent", "commit.format": "conventional", "intent.window": "7", + "intent.settle_window": "15s", "intent.recent_commits": "3", "intent.defer_limit": "1", } { @@ -451,6 +452,7 @@ func TestStatus_IntentStrategyUsesDaemonMetadata(t *testing.T) { if !rep.IntentStrategy.Active || rep.IntentStrategy.Strategy != "intent" || rep.IntentStrategy.CommitFormat != "conventional" || rep.IntentStrategy.Window != 7 || rep.IntentStrategy.RecentCommits != 3 || + rep.IntentStrategy.SettleWindowSeconds != 15 || rep.IntentStrategy.DeferLimit != 1 { t.Fatalf("intent strategy = %+v, want daemon metadata", rep.IntentStrategy) } @@ -471,6 +473,7 @@ func TestStatus_IntentStrategyReportsBatchWaitState(t *testing.T) { "commit.strategy": "intent", "intent.window": "7", "intent.min_pending": "3", + "intent.settle_window": "0s", "intent.max_pending_age": "2m", "intent.recent_commits": "3", "intent.defer_limit": "1", From d369affc949aad94d8148db08f028b839036229e Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:09:07 +0300 Subject: [PATCH 04/42] Expose settle window in intent reports - Surface settle-window timing in intent strategy output - Keep status and diagnose assertions aligned with the new field --- internal/cli/intent_observability_test.go | 6 ++++++ internal/cli/status_test.go | 1 + 2 files changed, 7 insertions(+) diff --git a/internal/cli/intent_observability_test.go b/internal/cli/intent_observability_test.go index 9b2007b6..94f71548 100644 --- a/internal/cli/intent_observability_test.go +++ b/internal/cli/intent_observability_test.go @@ -316,6 +316,9 @@ func TestStatus_IntentStageDiffCapExposedInJSON(t *testing.T) { if got := report.IntentStrategy.IntentStageDiffCap; got != ai.IntentStageDiffCap { t.Fatalf("IntentStageDiffCap=%d want %d", got, ai.IntentStageDiffCap) } + if got, want := report.IntentStrategy.SettleWindowSeconds, int64(ai.DefaultIntentSettleWindow/time.Second); got != want { + t.Fatalf("SettleWindowSeconds=%d want %d", got, want) + } } // TestDiagnose_IntentStageDiffCapExposedInJSON mirrors the status check @@ -338,6 +341,9 @@ func TestDiagnose_IntentStageDiffCapExposedInJSON(t *testing.T) { if got := report.IntentStrategy.IntentStageDiffCap; got != ai.IntentStageDiffCap { t.Fatalf("diagnose IntentStageDiffCap=%d want %d", got, ai.IntentStageDiffCap) } + if got, want := report.IntentStrategy.SettleWindowSeconds, int64(ai.DefaultIntentSettleWindow/time.Second); got != want { + t.Fatalf("diagnose SettleWindowSeconds=%d want %d", got, want) + } } // TestDefaultIntentDeferLimit_TunedToOne asserts the tuned default for diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go index 346e77e6..de9cf554 100644 --- a/internal/cli/status_test.go +++ b/internal/cli/status_test.go @@ -497,6 +497,7 @@ func TestStatus_IntentStrategyReportsBatchWaitState(t *testing.T) { rep.IntentStrategy.BatchWaitReason != "skipped_due_intent_batch_wait" || rep.IntentStrategy.VisiblePendingEvents != 2 || rep.IntentStrategy.MinPending != 3 || + rep.IntentStrategy.SettleWindowSeconds != 0 || rep.IntentStrategy.MaxPendingAgeSeconds != 120 || rep.IntentStrategy.OldestPendingEventSeq != seq || rep.IntentStrategy.OldestPendingPath != "wait-a.go" { From 8c857fc3224b95565f43aa94b951b1a7e6a67e4a Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:12:43 +0300 Subject: [PATCH 05/42] Disable coalescing by default - Keep every durable same-path capture visible unless opted in. - Require explicit env values to re-enable destructive squash. - Align tests with the new default and accepted spellings. --- internal/daemon/coalesce.go | 33 ++++++++++++++++---------------- internal/daemon/coalesce_test.go | 8 ++++---- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/internal/daemon/coalesce.go b/internal/daemon/coalesce.go index a0147a8c..91ab0611 100644 --- a/internal/daemon/coalesce.go +++ b/internal/daemon/coalesce.go @@ -1,5 +1,5 @@ -// coalesce.go folds runs of consecutive single-path captures into a single -// offered window entry before the intent planner sees them. +// coalesce.go contains the legacy same-path squash helper for runs of +// consecutive single-path captures. // // Why: a burst of edits on one file (save, save, save, save) currently fans // the planner request out to N entries. The planner sees N decisions where @@ -35,10 +35,11 @@ // either change the path identity or the path's existence, both of which // break the "single same-path edit chain" invariant. // -// ACD_INTENT_PATH_COALESCE controls the feature. It defaults ON; set to -// "0", "false", "no", or "off" (case-insensitive) to disable. The daemon -// resolves the env once per replay pass, so changes require a daemon -// restart (matches the existing env-restart pattern documented in +// ACD_INTENT_PATH_COALESCE controls the legacy feature. It defaults OFF so +// every durable capture remains planner-visible; set to "1", "true", "yes", +// or "on" (case-insensitive) to opt back into destructive same-path squash. +// The daemon resolves the env once per replay pass, so changes require a +// daemon restart (matches the existing env-restart pattern documented in // CLAUDE.md). package daemon @@ -90,25 +91,25 @@ type coalescedOffer struct { Token coalesceToken } -// envIntentPathCoalesce is the env var that controls the coalesce pass. -// Default ON to ship the new behavior automatically; "0"/"false"/"no"/"off" -// (case-insensitive) opts out. Mirrors the existing env-restart pattern — -// the daemon resolves the env once per replay pass, so toggling requires a -// restart. +// envIntentPathCoalesce is the env var that controls the legacy coalesce +// pass. Empty / unset disables coalescing; "1"/"true"/"yes"/"on" +// (case-insensitive) opts in. Mirrors the existing env-restart pattern — the +// daemon resolves the env once per replay pass, so toggling requires a restart. const envIntentPathCoalesce = "ACD_INTENT_PATH_COALESCE" // pathCoalesceEnabled reports whether ACD_INTENT_PATH_COALESCE permits -// folding. Empty / unset / any non-disable spelling enables coalesce. +// folding. Empty / unset / unrecognized spellings keep coalesce disabled so +// the planner sees every original capture seq. func pathCoalesceEnabled() bool { raw := strings.TrimSpace(os.Getenv(envIntentPathCoalesce)) if raw == "" { - return true + return false } switch strings.ToLower(raw) { - case "0", "false", "no", "off": - return false - default: + case "1", "true", "yes", "on": return true + default: + return false } } diff --git a/internal/daemon/coalesce_test.go b/internal/daemon/coalesce_test.go index 1bff0f27..0664dc73 100644 --- a/internal/daemon/coalesce_test.go +++ b/internal/daemon/coalesce_test.go @@ -396,16 +396,16 @@ func TestCoalesceIntentWindow_MultiPathEventDoesNotCoalesce(t *testing.T) { func TestCoalesceIntentWindow_PathCoalesceEnabledRespectsEnv(t *testing.T) { t.Setenv(envIntentPathCoalesce, "") - if !pathCoalesceEnabled() { - t.Fatal("empty env should default ON") + if pathCoalesceEnabled() { + t.Fatal("empty env should default OFF") } - for _, off := range []string{"0", "false", "FALSE", "no", "NO", "off", "Off"} { + for _, off := range []string{"0", "false", "FALSE", "no", "NO", "off", "Off", "anything-else"} { t.Setenv(envIntentPathCoalesce, off) if pathCoalesceEnabled() { t.Fatalf("env=%q should disable coalesce", off) } } - for _, on := range []string{"1", "true", "TRUE", "yes", "on", "anything-else"} { + for _, on := range []string{"1", "true", "TRUE", "yes", "on"} { t.Setenv(envIntentPathCoalesce, on) if !pathCoalesceEnabled() { t.Fatalf("env=%q should enable coalesce", on) From 00633d28c7221c468288ddb0d34d6cc74279d163 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:13:03 +0300 Subject: [PATCH 06/42] Update replay test for same-path captures - Add a helper to inspect offered capture seqs in planner requests. - Update the replay test to keep four same-path edits visible. - Assert the planner can select the full chain as one commit. --- internal/daemon/replay_test.go | 37 ++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/internal/daemon/replay_test.go b/internal/daemon/replay_test.go index 4cca5418..9ab44427 100644 --- a/internal/daemon/replay_test.go +++ b/internal/daemon/replay_test.go @@ -4068,6 +4068,14 @@ func (p *recordingIntentPlanner) PlanIntent(ctx context.Context, req ai.IntentPl return p.plan, nil } +func offeredCaptureSeqs(req ai.IntentPlanRequest) []int64 { + seqs := make([]int64, 0, len(req.OfferedCaptures)) + for _, capture := range req.OfferedCaptures { + seqs = append(seqs, capture.Seq) + } + return seqs +} + type qualityRewriteIntentProvider struct { name string planSubject string @@ -5086,11 +5094,12 @@ func captureSamePathEdit(t *testing.T, ctx context.Context, f *captureFixture, p return newest } -// TestReplay_IntentPathCoalesce_FoldsFourEditsIntoOneOffer: four sequential -// modifies on the same path produce one offered window entry, one commit, and -// four decision_records rows joined by commit_oid (so the CLI's grouped_seqs +// TestReplay_IntentSamePathCapturesRemainPlannerVisible: four sequential +// modifies on the same path remain four offered planner entries. The planner +// may still select all four into one commit, and decision_records must carry +// one row per original seq joined by commit_oid (so the CLI's grouped_seqs // derivation reports len 4). -func TestReplay_IntentPathCoalesce_FoldsFourEditsIntoOneOffer(t *testing.T) { +func TestReplay_IntentSamePathCapturesRemainPlannerVisible(t *testing.T) { f := newCaptureFixture(t) ctx := context.Background() @@ -5117,11 +5126,11 @@ func TestReplay_IntentPathCoalesce_FoldsFourEditsIntoOneOffer(t *testing.T) { } planner := &recordingIntentPlanner{} - // Plan the single coalesced offer the planner is expected to see. + // Plan every captured seq the planner is expected to see. planner.plan = ai.IntentPlan{ - SelectedSeqs: []int64{seq1}, - Subject: "Coalesced burst", - GroupingReason: "single-path edit chain", + SelectedSeqs: []int64{seq1, seq2, seq3, seq4}, + Subject: "Grouped burst", + GroupingReason: "same-path edit chain selected atomically", } sum, err := Replay(ctx, f.dir, f.db, f.cctx, ReplayOpts{ @@ -5141,13 +5150,15 @@ func TestReplay_IntentPathCoalesce_FoldsFourEditsIntoOneOffer(t *testing.T) { if planner.calls != 1 { t.Fatalf("planner calls=%d want 1", planner.calls) } - if got := planner.requests[0].OfferedCaptures; len(got) != 1 { - t.Fatalf("offered captures=%d want 1 (coalesced)", len(got)) + gotOffered := offeredCaptureSeqs(planner.requests[0]) + wantOffered := []int64{seq1, seq2, seq3, seq4} + if !reflect.DeepEqual(gotOffered, wantOffered) { + t.Fatalf("offered seqs=%v want %v (same-path captures must stay planner-visible)", gotOffered, wantOffered) } - // One commit on top of the seed for the coalesced burst. + // One commit on top of the seed for the planner-selected burst. if got := revListCount(t, ctx, f.dir, "HEAD"); got != 3 { - // seed (gitignore) + seed (burst.txt v0) + 1 coalesced commit = 3 - t.Fatalf("commit count=%d want 3 (gitignore seed + burst seed + 1 coalesced)", got) + // seed (gitignore) + seed (burst.txt v0) + 1 grouped commit = 3 + t.Fatalf("commit count=%d want 3 (gitignore seed + burst seed + 1 grouped)", got) } // HEAD's blob for burst.txt must be v4 (the LAST captured after-state). headOID, err := git.LsTreeBlobOID(ctx, f.dir, "HEAD", "burst.txt") From 5e9ade8cc2c8c3be74f47c7a828f5185936ad6a2 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:13:17 +0300 Subject: [PATCH 07/42] Add same-file planner visibility test - Cover independent edits in the same file so planner still sees both captures. - Reuse the offered-seq helper in replay assertions to simplify checks. --- internal/daemon/replay_test.go | 55 ++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/internal/daemon/replay_test.go b/internal/daemon/replay_test.go index 9ab44427..2b00043e 100644 --- a/internal/daemon/replay_test.go +++ b/internal/daemon/replay_test.go @@ -5221,6 +5221,53 @@ func TestReplay_IntentSamePathCapturesRemainPlannerVisible(t *testing.T) { } } +func TestReplay_IntentSameFileIndependentFunctionsRemainPlannerVisible(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + + seedTrackedFileCommit(t, ctx, f, "module.py", "def price():\n return 10\n\n\ndef slug():\n return 'old'\n") + if _, err := BootstrapShadow(ctx, f.dir, f.db, f.cctx); err != nil { + t.Fatalf("BootstrapShadow: %v", err) + } + + seq1 := captureSamePathEdit(t, ctx, f, "module.py", "def price():\n return 12\n\n\ndef slug():\n return 'old'\n") + seq2 := captureSamePathEdit(t, ctx, f, "module.py", "def price():\n return 12\n\n\ndef slug():\n return 'new'\n") + + pending, err := state.PendingEvents(ctx, f.db, 0) + if err != nil { + t.Fatalf("PendingEvents: %v", err) + } + if len(pending) != 2 { + t.Fatalf("pending=%d want 2 same-file captures", len(pending)) + } + planner := &recordingIntentPlanner{ + plan: ai.IntentPlan{ + SelectedSeqs: []int64{seq1, seq2}, + Subject: "Group module updates", + GroupingReason: "test selects both visible same-file captures", + }, + } + + sum, err := Replay(ctx, f.dir, f.db, f.cctx, ReplayOpts{ + GitDir: f.gitDir, + CommitStrategy: ai.CommitStrategyIntent, + IntentPlanner: planner, + IntentWindow: 10, + IntentMinPending: 2, + }) + if err != nil { + t.Fatalf("Replay: %v", err) + } + if sum.Published != 2 || sum.Conflicts != 0 || sum.Failed != 0 { + t.Fatalf("summary=%+v want 2 published, 0 conflicts/failed", sum) + } + gotOffered := offeredCaptureSeqs(planner.requests[0]) + wantOffered := []int64{seq1, seq2} + if !reflect.DeepEqual(gotOffered, wantOffered) { + t.Fatalf("offered seqs=%v want %v (independent same-file captures must stay planner-visible)", gotOffered, wantOffered) + } +} + // TestReplay_IntentPathCoalesce_PQPDoesNotCoalesce: a same-path capture // surrounded by an other-path capture stays as 3 separate offers. func TestReplay_IntentPathCoalesce_PQPDoesNotCoalesce(t *testing.T) { @@ -5269,12 +5316,8 @@ func TestReplay_IntentPathCoalesce_PQPDoesNotCoalesce(t *testing.T) { if got := planner.requests[0].OfferedCaptures; len(got) != 3 { t.Fatalf("planner offered captures=%d want 3 (P/Q/P must split)", len(got)) } - // Verify each offer is a distinct seq (no coalesce_token) — read from - // the offered captures' seqs. - offeredSeqs := make([]int64, 0, 3) - for _, c := range planner.requests[0].OfferedCaptures { - offeredSeqs = append(offeredSeqs, c.Seq) - } + // Verify each offer is a distinct seq (no coalesce_token). + offeredSeqs := offeredCaptureSeqs(planner.requests[0]) wantOffered := []int64{pending[0].Seq, pending[1].Seq, pending[2].Seq} if !reflect.DeepEqual(offeredSeqs, wantOffered) { t.Fatalf("offered seqs=%v want %v", offeredSeqs, wantOffered) From 8e945adad7a5ee867b25cd56088a0bc8288e8c92 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:13:50 +0300 Subject: [PATCH 08/42] Add intent settle-window gating - Treat recent rapid captures as settling before planning - Cover both the settle-window skip and the eventual 5-capture batch --- internal/daemon/replay.go | 30 ++++++++++-- internal/daemon/replay_test.go | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/internal/daemon/replay.go b/internal/daemon/replay.go index b832f8fa..8d7c608f 100644 --- a/internal/daemon/replay.go +++ b/internal/daemon/replay.go @@ -1228,8 +1228,10 @@ func selectIntentWindow(ctx context.Context, db *state.DB, pending []state.Captu } return []state.CaptureEvent{forcedEvent}, true, "", nil } - if !cfg.bypassBatchWait && intentBatchShouldWait(pending, cfg, time.Now()) { - return nil, false, "skipped_due_intent_batch_wait", nil + if !cfg.bypassBatchWait { + if waitReason := intentBatchWaitReason(pending, cfg, time.Now()); waitReason != "" { + return nil, false, waitReason, nil + } } n := cfg.window if n > len(pending) { @@ -1361,12 +1363,32 @@ func persistPathQuiescenceSnapshot(ctx context.Context, db *state.DB, gated int, } func intentBatchShouldWait(pending []state.CaptureEvent, cfg intentReplayConfig, now time.Time) bool { + return intentBatchWaitReason(pending, cfg, now) != "" +} + +func intentBatchWaitReason(pending []state.CaptureEvent, cfg intentReplayConfig, now time.Time) string { if len(pending) == 0 || len(pending) >= cfg.minPending { - return false + if len(pending) == 0 || cfg.settleWindow <= 0 { + return "" + } + oldest := pending[0] + oldestAge := now.Sub(time.Unix(0, int64(oldest.CapturedTS*float64(time.Second)))) + if oldestAge >= cfg.maxPendingAge { + return "" + } + newest := pending[len(pending)-1] + newestAge := now.Sub(time.Unix(0, int64(newest.CapturedTS*float64(time.Second)))) + if newestAge < cfg.settleWindow { + return "skipped_due_intent_settle_window" + } + return "" } oldest := pending[0] oldestAge := now.Sub(time.Unix(0, int64(oldest.CapturedTS*float64(time.Second)))) - return oldestAge < cfg.maxPendingAge + if oldestAge < cfg.maxPendingAge { + return "skipped_due_intent_batch_wait" + } + return "" } func intentPreflightEvents(pending, window []state.CaptureEvent, forced bool) []state.CaptureEvent { diff --git a/internal/daemon/replay_test.go b/internal/daemon/replay_test.go index 2b00043e..232b3279 100644 --- a/internal/daemon/replay_test.go +++ b/internal/daemon/replay_test.go @@ -2490,6 +2490,90 @@ func TestReplay_IntentStrategyPlansWhenMinPendingReached(t *testing.T) { } } +func TestReplay_IntentStrategySettleWindowKeepsRapidFiveTogether(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + + if _, err := BootstrapShadow(ctx, f.dir, f.db, f.cctx); err != nil { + t.Fatalf("BootstrapShadow: %v", err) + } + captureOnePendingFile(t, ctx, f, "rapid-a.txt", "a\n") + captureOnePendingFile(t, ctx, f, "rapid-b.txt", "b\n") + + prematurePlanner := &recordingIntentPlanner{ + err: errors.New("planner must not run before settle window elapses"), + } + trace := &memoryTraceLogger{} + sum, err := Replay(ctx, f.dir, f.db, f.cctx, ReplayOpts{ + GitDir: f.gitDir, + Trace: trace, + CommitStrategy: ai.CommitStrategyIntent, + IntentPlanner: prematurePlanner, + IntentWindow: 10, + IntentMinPending: 2, + IntentSettleWindow: time.Hour, + IntentMaxPendingAge: 2 * time.Hour, + }) + if err != nil { + t.Fatalf("Replay before settle: %v", err) + } + if prematurePlanner.calls != 0 { + t.Fatalf("planner calls=%d want 0 while rapid burst is settling", prematurePlanner.calls) + } + if sum.Published != 0 || !sum.Skipped || sum.SkippedReason != "skipped_due_intent_settle_window" { + t.Fatalf("summary=%+v want skipped_due_intent_settle_window with no publish", sum) + } + events := traceEventsByClass(trace.Events(), "intent.batch_wait") + if len(events) != 1 || events[0].Reason != "skipped_due_intent_settle_window" { + t.Fatalf("intent.batch_wait trace=%+v want skipped_due_intent_settle_window", events) + } + + captureOnePendingFile(t, ctx, f, "rapid-c.txt", "c\n") + captureOnePendingFile(t, ctx, f, "rapid-d.txt", "d\n") + captureOnePendingFile(t, ctx, f, "rapid-e.txt", "e\n") + pending, err := state.PendingEvents(ctx, f.db, 0) + if err != nil { + t.Fatalf("PendingEvents: %v", err) + } + if len(pending) != 5 { + t.Fatalf("pending=%d want 5 rapid captures", len(pending)) + } + allSeqs := make([]int64, 0, len(pending)) + for _, ev := range pending { + allSeqs = append(allSeqs, ev.Seq) + setCaptureEventTimestamp(t, ctx, f.db, ev.Seq, time.Now().Add(-time.Minute)) + } + planner := &recordingIntentPlanner{ + plan: ai.IntentPlan{ + SelectedSeqs: allSeqs, + Subject: "Group rapid five", + GroupingReason: "settle window collected the rapid burst", + }, + } + sum, err = Replay(ctx, f.dir, f.db, f.cctx, ReplayOpts{ + GitDir: f.gitDir, + CommitStrategy: ai.CommitStrategyIntent, + IntentPlanner: planner, + IntentWindow: 10, + IntentMinPending: 2, + IntentSettleWindow: 10 * time.Second, + IntentMaxPendingAge: 2 * time.Hour, + }) + if err != nil { + t.Fatalf("Replay after settle: %v", err) + } + if sum.Published != 5 || sum.Skipped { + t.Fatalf("summary=%+v want five rapid captures published together", sum) + } + if planner.calls != 1 { + t.Fatalf("planner calls=%d want 1 after settle", planner.calls) + } + gotOffered := offeredCaptureSeqs(planner.requests[0]) + if !reflect.DeepEqual(gotOffered, allSeqs) { + t.Fatalf("offered seqs=%v want all rapid seqs %v", gotOffered, allSeqs) + } +} + func TestReplay_IntentStrategyBatchGateUsesVisiblePendingBeyondReplayLimit(t *testing.T) { f := newCaptureFixture(t) ctx := context.Background() From ecfe6ab19f418036f024b6466d40ceb538e2511e Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:14:26 +0300 Subject: [PATCH 09/42] Add settle-window observability - Record settle-window context in daemon traces. - Expose newest-pending and settle-trigger fields in CLI reports. --- internal/cli/intent_observability.go | 4 ++++ internal/daemon/replay.go | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/internal/cli/intent_observability.go b/internal/cli/intent_observability.go index 0d7f293b..544be705 100644 --- a/internal/cli/intent_observability.go +++ b/internal/cli/intent_observability.go @@ -70,8 +70,12 @@ type intentStrategyReport struct { OldestPendingEventSeq int64 `json:"oldest_pending_event_seq,omitempty"` OldestPendingPath string `json:"oldest_pending_path,omitempty"` OldestPendingAgeSeconds int64 `json:"oldest_pending_age_seconds,omitempty"` + NewestPendingEventSeq int64 `json:"newest_pending_event_seq,omitempty"` + NewestPendingAgeSeconds int64 `json:"newest_pending_age_seconds,omitempty"` AgeTriggerTS int64 `json:"age_trigger_ts,omitempty"` AgeTriggerInSeconds int64 `json:"age_trigger_in_seconds,omitempty"` + SettleTriggerTS int64 `json:"settle_trigger_ts,omitempty"` + SettleTriggerInSeconds int64 `json:"settle_trigger_in_seconds,omitempty"` BatchWaitActive bool `json:"batch_wait_active,omitempty"` BatchWaitReason string `json:"batch_wait_reason,omitempty"` DeferredEvents int `json:"deferred_events,omitempty"` diff --git a/internal/daemon/replay.go b/internal/daemon/replay.go index 8d7c608f..634b7d19 100644 --- a/internal/daemon/replay.go +++ b/internal/daemon/replay.go @@ -2145,6 +2145,7 @@ func traceIntentPlannerInput(logger acdtrace.Logger, repoRoot string, cctx Captu "path_commit_context_count": len(req.PathCommitContext), "window": cfg.window, "min_pending": cfg.minPending, + "settle_window_seconds": cfg.settleWindow.Seconds(), "max_pending_age_seconds": cfg.maxPendingAge.Seconds(), "recent_commits": cfg.recent, "defer_limit": cfg.deferLimit, @@ -2159,6 +2160,8 @@ func traceIntentBatchWait(logger acdtrace.Logger, repoRoot string, cctx CaptureC } oldest := pending[0] oldestAgeSeconds := time.Now().Sub(time.Unix(0, int64(oldest.CapturedTS*float64(time.Second)))).Seconds() + newest := pending[len(pending)-1] + newestAgeSeconds := time.Now().Sub(time.Unix(0, int64(newest.CapturedTS*float64(time.Second)))).Seconds() logger.Record(acdtrace.Event{ Repo: repoRoot, BranchRef: cctx.BranchRef, @@ -2171,6 +2174,9 @@ func traceIntentBatchWait(logger acdtrace.Logger, repoRoot string, cctx CaptureC "min_pending": cfg.minPending, "oldest_seq": oldest.Seq, "oldest_age_seconds": oldestAgeSeconds, + "newest_seq": newest.Seq, + "newest_age_seconds": newestAgeSeconds, + "settle_window_seconds": cfg.settleWindow.Seconds(), "max_pending_age_seconds": cfg.maxPendingAge.Seconds(), "window": cfg.window, }, From e3ac4f311705b5066e291d536a6c461070fa30bd Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:14:46 +0300 Subject: [PATCH 10/42] Add settle-window details to intent observability - Distinguish settle-window waits from generic batch waits in CLI output - Report the newest pending event age and settle trigger timing - Keep planner observability aligned with the pending window semantics --- internal/cli/intent_observability.go | 52 ++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/internal/cli/intent_observability.go b/internal/cli/intent_observability.go index 544be705..8cf2a298 100644 --- a/internal/cli/intent_observability.go +++ b/internal/cli/intent_observability.go @@ -171,12 +171,20 @@ func renderIntentStrategyHuman(out io.Writer, r intentStrategyReport) { fmt.Fprintf(out, "Commit strategy: %s format=%s\n", status, valueOrUnset(r.CommitFormat)) } if r.BatchWaitActive { - fmt.Fprintf(out, "Intent batch wait: pending=%d min_pending=%d oldest_age=%s max_age=%s trigger_in=%s\n", - r.VisiblePendingEvents, - r.MinPending, - formatDurationCompact(time.Duration(r.OldestPendingAgeSeconds)*time.Second), - formatDurationCompact(time.Duration(r.MaxPendingAgeSeconds)*time.Second), - formatDurationCompact(time.Duration(r.AgeTriggerInSeconds)*time.Second)) + if r.BatchWaitReason == "skipped_due_intent_settle_window" { + fmt.Fprintf(out, "Intent settle wait: pending=%d newest_age=%s settle=%s trigger_in=%s\n", + r.VisiblePendingEvents, + formatDurationCompact(time.Duration(r.NewestPendingAgeSeconds)*time.Second), + formatDurationCompact(time.Duration(r.SettleWindowSeconds)*time.Second), + formatDurationCompact(time.Duration(r.SettleTriggerInSeconds)*time.Second)) + } else { + fmt.Fprintf(out, "Intent batch wait: pending=%d min_pending=%d oldest_age=%s max_age=%s trigger_in=%s\n", + r.VisiblePendingEvents, + r.MinPending, + formatDurationCompact(time.Duration(r.OldestPendingAgeSeconds)*time.Second), + formatDurationCompact(time.Duration(r.MaxPendingAgeSeconds)*time.Second), + formatDurationCompact(time.Duration(r.AgeTriggerInSeconds)*time.Second)) + } } if r.DeferredEvents > 0 || r.ForcedAgingReady > 0 || r.LastPlannerError != "" { fmt.Fprintf(out, "Intent planner: deferred=%d max_defer=%d forced_ready=%d\n", @@ -628,6 +636,8 @@ func loadIntentBatchWait(ctx context.Context, conn *sql.DB, report *intentStrate var oldestSeq sql.NullInt64 var oldestPath sql.NullString var oldestCaptured sql.NullFloat64 + var newestSeq sql.NullInt64 + var newestCaptured sql.NullFloat64 if err := conn.QueryRowContext(ctx, ` WITH barriers AS ( SELECT branch_ref, branch_generation, MIN(seq) AS first_seq @@ -647,12 +657,18 @@ SELECT COUNT(*), MIN(seq), ( SELECT path FROM visible_pending ORDER BY seq ASC LIMIT 1 ), ( SELECT captured_ts FROM visible_pending ORDER BY seq ASC LIMIT 1 +), ( + SELECT seq FROM visible_pending ORDER BY seq DESC LIMIT 1 +), ( + SELECT captured_ts FROM visible_pending ORDER BY seq DESC LIMIT 1 ) FROM visible_pending`, state.EventStateBlockedConflict, state.EventStateFailed, state.EventStatePending).Scan( &report.VisiblePendingEvents, &oldestSeq, &oldestPath, &oldestCaptured, + &newestSeq, + &newestCaptured, ); err != nil { return fmt.Errorf("intent batch wait summary: %w", err) } @@ -667,6 +683,9 @@ FROM visible_pending`, state.EventStateBlockedConflict, state.EventStateFailed, if oldestPath.Valid { report.OldestPendingPath = oldestPath.String } + if newestSeq.Valid { + report.NewestPendingEventSeq = newestSeq.Int64 + } // Path-quiescence aware reporting: when the daemon stamped a recent // gated-count snapshot we subtract it from VisiblePendingEvents so the // number reflects the planner-visible window, not the durable FIFO @@ -710,12 +729,33 @@ FROM visible_pending`, state.EventStateBlockedConflict, state.EventStateFailed, if remaining := report.AgeTriggerTS - int64(nowSec); remaining > 0 { report.AgeTriggerInSeconds = remaining } + if newestCaptured.Valid { + newestAgeSeconds := int64(nowSec - newestCaptured.Float64) + if newestAgeSeconds < 0 { + newestAgeSeconds = 0 + } + report.NewestPendingAgeSeconds = newestAgeSeconds + if report.SettleWindowSeconds > 0 { + report.SettleTriggerTS = int64(newestCaptured.Float64) + report.SettleWindowSeconds + if remaining := report.SettleTriggerTS - int64(nowSec); remaining > 0 { + report.SettleTriggerInSeconds = remaining + } + } + } if report.Active && report.ForcedAgingReady == 0 && report.VisiblePendingEvents < report.MinPending && report.OldestPendingAgeSeconds < report.MaxPendingAgeSeconds { report.BatchWaitActive = true report.BatchWaitReason = "skipped_due_intent_batch_wait" + } else if report.Active && + report.ForcedAgingReady == 0 && + report.VisiblePendingEvents >= report.MinPending && + report.SettleWindowSeconds > 0 && + report.OldestPendingAgeSeconds < report.MaxPendingAgeSeconds && + report.NewestPendingAgeSeconds < report.SettleWindowSeconds { + report.BatchWaitActive = true + report.BatchWaitReason = "skipped_due_intent_settle_window" } return nil } From 2243d0b13e13c115db32393ac1e6113b98623ad8 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:15:11 +0300 Subject: [PATCH 11/42] Report intent settle waits in status - Differentiate settle-window waiting from general batch waiting - Surface the correct countdown when intent is paused for settling - Add coverage for JSON and human status output in the settle case --- internal/cli/list.go | 27 ++++++++++++++---- internal/cli/status_test.go | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/internal/cli/list.go b/internal/cli/list.go index 5a6ca1c1..82895d42 100644 --- a/internal/cli/list.go +++ b/internal/cli/list.go @@ -253,10 +253,16 @@ func collectListSnapshot(ctx context.Context, errOut io.Writer) (listSnapshot, e e.IntentWaitSeconds = summary.intentWait.waitSeconds e.IntentVisiblePending = summary.intentWait.visiblePending e.IntentMinPending = summary.intentWait.minPending - e.StatusNote = fmt.Sprintf("intent batch wait: pending=%d/%d, trigger in %s", - summary.intentWait.visiblePending, - summary.intentWait.minPending, - formatDurationCompact(time.Duration(summary.intentWait.waitSeconds)*time.Second)) + if summary.intentWait.reason == "skipped_due_intent_settle_window" { + e.StatusNote = fmt.Sprintf("intent settle wait: pending=%d, trigger in %s", + summary.intentWait.visiblePending, + formatDurationCompact(time.Duration(summary.intentWait.waitSeconds)*time.Second)) + } else { + e.StatusNote = fmt.Sprintf("intent batch wait: pending=%d/%d, trigger in %s", + summary.intentWait.visiblePending, + summary.intentWait.minPending, + formatDurationCompact(time.Duration(summary.intentWait.waitSeconds)*time.Second)) + } } } if summary.pause != nil { @@ -405,6 +411,7 @@ type listIntentWaitSummary struct { waitSeconds int64 visiblePending int minPending int + reason string } // summarizeRepo opens the per-repo state.db read-only and pulls a small @@ -573,13 +580,21 @@ WHERE e.state = ? AND ps.defer_count >= ? if err := loadIntentBatchWait(ctx, conn, &report); err != nil { return nil, err } - if !report.BatchWaitActive || report.AgeTriggerInSeconds <= 0 { + if !report.BatchWaitActive { + return nil, nil + } + waitSeconds := report.AgeTriggerInSeconds + if report.BatchWaitReason == "skipped_due_intent_settle_window" { + waitSeconds = report.SettleTriggerInSeconds + } + if waitSeconds <= 0 { return nil, nil } return &listIntentWaitSummary{ - waitSeconds: report.AgeTriggerInSeconds, + waitSeconds: waitSeconds, visiblePending: report.VisiblePendingEvents, minPending: report.MinPending, + reason: report.BatchWaitReason, }, nil } diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go index de9cf554..9fe3f863 100644 --- a/internal/cli/status_test.go +++ b/internal/cli/status_test.go @@ -516,6 +516,62 @@ func TestStatus_IntentStrategyReportsBatchWaitState(t *testing.T) { } } +func TestStatus_IntentStrategyReportsSettleWaitState(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + + repo, dbPath, d := makeRepoStateDB(t) + registerRepo(t, roots, repo, dbPath, "codex") + if err := state.SaveDaemonState(ctx, d, state.DaemonState{ + PID: os.Getpid(), Mode: "running", HeartbeatTS: nowFloat(), + }); err != nil { + t.Fatalf("save state: %v", err) + } + for k, v := range map[string]string{ + "commit.strategy": "intent", + "intent.window": "7", + "intent.min_pending": "2", + "intent.settle_window": "1m", + "intent.max_pending_age": "2m", + "intent.recent_commits": "3", + "intent.defer_limit": "1", + } { + if err := state.MetaSet(ctx, d, k, v); err != nil { + t.Fatalf("set %s: %v", k, err) + } + } + appendIntentPendingEvent(t, ctx, d, "settle-a.go", nowFloat()-10) + newest := appendIntentPendingEvent(t, ctx, d, "settle-b.go", nowFloat()-5) + + var jsonOut bytes.Buffer + if err := runStatus(ctx, &jsonOut, repo, true); err != nil { + t.Fatalf("runStatus json: %v", err) + } + var rep statusReport + if err := json.Unmarshal(jsonOut.Bytes(), &rep); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, jsonOut.String()) + } + if !rep.IntentStrategy.BatchWaitActive || + rep.IntentStrategy.BatchWaitReason != "skipped_due_intent_settle_window" || + rep.IntentStrategy.VisiblePendingEvents != 2 || + rep.IntentStrategy.MinPending != 2 || + rep.IntentStrategy.SettleWindowSeconds != 60 || + rep.IntentStrategy.NewestPendingEventSeq != newest { + t.Fatalf("intent strategy = %+v, want active settle wait", rep.IntentStrategy) + } + if rep.IntentStrategy.NewestPendingAgeSeconds <= 0 || rep.IntentStrategy.SettleTriggerInSeconds <= 0 { + t.Fatalf("intent settle ages = %+v, want positive newest age and trigger countdown", rep.IntentStrategy) + } + + var humanOut bytes.Buffer + if err := runStatus(ctx, &humanOut, repo, false); err != nil { + t.Fatalf("runStatus human: %v", err) + } + if !strings.Contains(humanOut.String(), "Intent settle wait: pending=2") { + t.Fatalf("status human missing settle wait line:\n%s", humanOut.String()) + } +} + func TestStatus_IntentStrategyUsesDurablePlannerErrorLedger(t *testing.T) { roots := withIsolatedHome(t) ctx := context.Background() From 4111464af98a080758bd09ce3d9d09c15b573f6d Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:15:46 +0300 Subject: [PATCH 12/42] Report intent settle waits consistently - Distinguish intent settle-window waits from age-based waits in status. - Cover compact, verbose, and JSON list output for the new wait path. --- internal/cli/diagnose.go | 26 ++++++++++------ internal/cli/list_test.go | 63 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/internal/cli/diagnose.go b/internal/cli/diagnose.go index e75626c6..a8c2e4e0 100644 --- a/internal/cli/diagnose.go +++ b/internal/cli/diagnose.go @@ -678,16 +678,24 @@ func diagnoseRemediation(report diagnoseReport) []string { report.IntentStrategy.ForcedAgingReady)) } if report.IntentStrategy.BatchWaitActive { - wait := formatDurationCompact(time.Duration(report.IntentStrategy.AgeTriggerInSeconds) * time.Second) - need := report.IntentStrategy.MinPending - report.IntentStrategy.VisiblePendingEvents - if need < 0 { - need = 0 + if report.IntentStrategy.BatchWaitReason == "skipped_due_intent_settle_window" { + wait := formatDurationCompact(time.Duration(report.IntentStrategy.SettleTriggerInSeconds) * time.Second) + remediation = append(remediation, + fmt.Sprintf("intent replay reached min pending and is waiting for the latest capture to stay quiet for %s (about %s remaining); wait, run `acd flush --logical --session-id ` to publish now, lower ACD_INTENT_SETTLE_WINDOW, or switch ACD_COMMIT_STRATEGY=event for immediate one-event commits.", + formatDurationCompact(time.Duration(report.IntentStrategy.SettleWindowSeconds)*time.Second), + wait)) + } else { + wait := formatDurationCompact(time.Duration(report.IntentStrategy.AgeTriggerInSeconds) * time.Second) + need := report.IntentStrategy.MinPending - report.IntentStrategy.VisiblePendingEvents + if need < 0 { + need = 0 + } + remediation = append(remediation, + fmt.Sprintf("intent replay is waiting for %d more pending capture(s) or the oldest pending capture to reach %s (about %s remaining); wait, run `acd flush --logical --session-id ` to publish now, lower ACD_INTENT_MIN_PENDING or ACD_INTENT_MAX_PENDING_AGE for sparse repos, or switch ACD_COMMIT_STRATEGY=event for immediate one-event commits.", + need, + formatDurationCompact(time.Duration(report.IntentStrategy.MaxPendingAgeSeconds)*time.Second), + wait)) } - remediation = append(remediation, - fmt.Sprintf("intent replay is waiting for %d more pending capture(s) or the oldest pending capture to reach %s (about %s remaining); wait, run `acd flush --logical --session-id ` to publish now, lower ACD_INTENT_MIN_PENDING or ACD_INTENT_MAX_PENDING_AGE for sparse repos, or switch ACD_COMMIT_STRATEGY=event for immediate one-event commits.", - need, - formatDurationCompact(time.Duration(report.IntentStrategy.MaxPendingAgeSeconds)*time.Second), - wait)) } if report.StaleOperationMarker { remediation = append(remediation, diff --git a/internal/cli/list_test.go b/internal/cli/list_test.go index 2a61d2d5..b3b7c1ec 100644 --- a/internal/cli/list_test.go +++ b/internal/cli/list_test.go @@ -629,6 +629,69 @@ func TestList_IntentBatchWaitShowsCountdown(t *testing.T) { } } +func TestList_IntentSettleWaitShowsCountdown(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + + repo, dbPath, d := makeRepoStateDB(t) + if err := state.SaveDaemonState(ctx, d, state.DaemonState{ + PID: os.Getpid(), Mode: "running", HeartbeatTS: nowFloat(), + }); err != nil { + t.Fatalf("save daemon: %v", err) + } + for k, v := range map[string]string{ + "commit.strategy": "intent", + "intent.min_pending": "2", + "intent.settle_window": "1m", + "intent.max_pending_age": "2m", + "intent.defer_limit": "1", + } { + if err := state.MetaSet(ctx, d, k, v); err != nil { + t.Fatalf("set %s: %v", k, err) + } + } + appendIntentPendingEvent(t, ctx, d, "settle-a.go", nowFloat()-10) + appendIntentPendingEvent(t, ctx, d, "settle-b.go", nowFloat()-5) + registerRepo(t, roots, repo, dbPath, "codex") + + var compactOut, compactErr bytes.Buffer + if err := runList(ctx, &compactOut, &compactErr, false, false); err != nil { + t.Fatalf("runList compact: %v", err) + } + if !strings.Contains(compactOut.String(), "wait") { + t.Fatalf("compact output missing wait status:\n%s", compactOut.String()) + } + + var verboseOut, verboseErr bytes.Buffer + if err := runList(ctx, &verboseOut, &verboseErr, false, true); err != nil { + t.Fatalf("runList verbose: %v", err) + } + if !strings.Contains(verboseOut.String(), "intent settle wait: pending=2, trigger in") { + t.Fatalf("verbose output missing intent settle note:\n%s", verboseOut.String()) + } + + var jsonOut, jsonErr bytes.Buffer + if err := runList(ctx, &jsonOut, &jsonErr, true, false); err != nil { + t.Fatalf("runList json: %v", err) + } + var got struct { + Repos []listEntry `json:"repos"` + } + if err := json.Unmarshal(jsonOut.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, jsonOut.String()) + } + if len(got.Repos) != 1 { + t.Fatalf("repos=%d, want 1", len(got.Repos)) + } + entry := got.Repos[0] + if entry.Status != "waiting" || entry.IntentVisiblePending != 2 || entry.IntentMinPending != 2 { + t.Fatalf("json entry missing intent settle fields: %+v", entry) + } + if entry.IntentWaitSeconds <= 0 || entry.IntentWaitSeconds > 60 { + t.Fatalf("intent wait seconds=%d, want 1..60", entry.IntentWaitSeconds) + } +} + func TestListStatusCompact(t *testing.T) { t.Parallel() tests := []struct { From 9117dcd9d3ebe55b44b4dc0f107268ccb2beaca0 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:16:37 +0300 Subject: [PATCH 13/42] Handle intent settle-window waits - Distinguish settle-window waits from the older batch-wait display. - Add coverage for a rapid burst that should stay grouped as one commit. --- internal/cli/doctor.go | 33 +++++-- test/integration/intent_strategy_test.go | 117 +++++++++++++++++++++++ 2 files changed, 144 insertions(+), 6 deletions(-) diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index cc42d60c..6122ddec 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -1290,6 +1290,19 @@ func doctorIntentStrategyNotes(r intentStrategyReport, repoPath, sessionID strin if !r.BatchWaitActive { return nil } + if r.BatchWaitReason == "skipped_due_intent_settle_window" { + wait := formatDurationCompact(time.Duration(r.SettleTriggerInSeconds) * time.Second) + sessionHint := sessionID + if sessionHint == "" { + sessionHint = "" + } + return []string{ + fmt.Sprintf("intent replay reached min pending and is waiting for the latest capture to stay quiet for %s (about %s remaining)", formatDurationCompact(time.Duration(r.SettleWindowSeconds)*time.Second), wait), + fmt.Sprintf("to publish now, run acd flush --logical --repo %s --session-id %s; explicit flushes bypass intent batch wait and require a registered active session id", repoPath, sessionHint), + "to shorten burst collection, lower ACD_INTENT_SETTLE_WINDOW and restart acd", + "to disable batching, set ACD_COMMIT_STRATEGY=event and restart acd", + } + } wait := formatDurationCompact(time.Duration(r.AgeTriggerInSeconds) * time.Second) need := r.MinPending - r.VisiblePendingEvents if need < 0 { @@ -1462,12 +1475,20 @@ func renderDoctorHuman(out io.Writer, r doctorReport) error { fmt.Fprintf(out, " strategy : %s active=%v deferred=%d forced_ready=%d\n", valueOrUnset(rr.IntentStrategy.Strategy), rr.IntentStrategy.Active, rr.IntentStrategy.DeferredEvents, rr.IntentStrategy.ForcedAgingReady) if rr.IntentStrategy.BatchWaitActive { - fmt.Fprintf(out, " batch wait : pending=%d min_pending=%d oldest_age=%s max_age=%s trigger_in=%s\n", - rr.IntentStrategy.VisiblePendingEvents, - rr.IntentStrategy.MinPending, - formatDurationCompact(time.Duration(rr.IntentStrategy.OldestPendingAgeSeconds)*time.Second), - formatDurationCompact(time.Duration(rr.IntentStrategy.MaxPendingAgeSeconds)*time.Second), - formatDurationCompact(time.Duration(rr.IntentStrategy.AgeTriggerInSeconds)*time.Second)) + if rr.IntentStrategy.BatchWaitReason == "skipped_due_intent_settle_window" { + fmt.Fprintf(out, " settle wait: pending=%d newest_age=%s settle=%s trigger_in=%s\n", + rr.IntentStrategy.VisiblePendingEvents, + formatDurationCompact(time.Duration(rr.IntentStrategy.NewestPendingAgeSeconds)*time.Second), + formatDurationCompact(time.Duration(rr.IntentStrategy.SettleWindowSeconds)*time.Second), + formatDurationCompact(time.Duration(rr.IntentStrategy.SettleTriggerInSeconds)*time.Second)) + } else { + fmt.Fprintf(out, " batch wait : pending=%d min_pending=%d oldest_age=%s max_age=%s trigger_in=%s\n", + rr.IntentStrategy.VisiblePendingEvents, + rr.IntentStrategy.MinPending, + formatDurationCompact(time.Duration(rr.IntentStrategy.OldestPendingAgeSeconds)*time.Second), + formatDurationCompact(time.Duration(rr.IntentStrategy.MaxPendingAgeSeconds)*time.Second), + formatDurationCompact(time.Duration(rr.IntentStrategy.AgeTriggerInSeconds)*time.Second)) + } } if rr.IntentStrategy.LastPlannerError != "" { fmt.Fprintf(out, " planner err: seq %d %s\n", diff --git a/test/integration/intent_strategy_test.go b/test/integration/intent_strategy_test.go index 1cc2b012..8a544b38 100644 --- a/test/integration/intent_strategy_test.go +++ b/test/integration/intent_strategy_test.go @@ -132,6 +132,123 @@ func TestIntentStrategy_OpenAIPlannerGroupsTwoCaptures(t *testing.T) { } } +func TestIntentStrategy_RapidFiveCapturesOfferedTogether(t *testing.T) { + if _, err := exec.LookPath("sqlite3"); err != nil { + t.Skip("sqlite3 binary required") + } + repo := tempRepo(t) + env := withIsolatedHome(t) + t.Cleanup(func() { stopSessionForce(t, env, repo) }) + + var hits atomic.Int32 + var firstOffered atomic.Value + server, trustEnv := newOpenAITestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + if !strings.HasSuffix(r.URL.Path, "/chat/completions") { + http.Error(w, "wrong path", http.StatusNotFound) + return + } + req := decodeIntentChatRequest(t, r) + seqs := offeredIntentSeqs(t, req) + if firstOffered.Load() == nil { + copied := append([]int64(nil), seqs...) + firstOffered.Store(copied) + } + if len(seqs) != 5 { + http.Error(w, "expected exactly five offered captures", http.StatusBadRequest) + return + } + plan := map[string]any{ + "selected_seqs": seqs, + "deferred_seqs": []int64{}, + "subject": "Group rapid five", + "body": "Publish all rapid captures together.", + "grouping_reason": "settle window collected the rapid burst", + "deferred_reasons": []map[string]any{}, + } + args, err := json.Marshal(plan) + if err != nil { + t.Fatalf("marshal intent plan: %v", err) + } + resp := map[string]any{ + "id": "chatcmpl-rapid-five", + "object": "chat.completion", + "model": "gpt-5.4-mini", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": "", + "tool_calls": []map[string]any{{ + "id": "call_rapid_five", + "type": "function", + "function": map[string]any{ + "name": "capture_intent_plan", + "arguments": string(args), + }, + }}, + }, + "finish_reason": "tool_calls", + }}, + } + body, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + extra := []string{ + "ACD_COMMIT_STRATEGY=intent", + "ACD_INTENT_WINDOW=10", + "ACD_INTENT_MIN_PENDING=2", + "ACD_INTENT_SETTLE_WINDOW=1s", + "ACD_INTENT_MAX_PENDING_AGE=30s", + "ACD_AI_PROVIDER=openai-compat", + "ACD_AI_BASE_URL=" + server.URL, + "ACD_AI_API_KEY=test-key", + "ACD_AI_MODEL=gpt-5.4-mini", + trustEnv, + } + sessionID := "intent-rapid-five" + startSession(t, ctx, env, repo, sessionID, "shell", extra...) + waitMode(t, repo, "running", 5*time.Second) + fullEnv := envWith(env, extra...) + + files := []string{"rapid-one.txt", "rapid-two.txt", "rapid-three.txt", "rapid-four.txt", "rapid-five.txt"} + startCount := commitCount(t, repo) + for _, name := range files { + writeFile(t, filepath.Join(repo, name), "rapid content for "+name+"\n") + } + wake := runAcd(t, ctx, fullEnv, "wake", "--repo", repo, "--session-id", sessionID, "--json") + if wake.ExitCode != 0 { + t.Fatalf("acd wake exit=%d\nstdout=%s\nstderr=%s", wake.ExitCode, wake.Stdout, wake.Stderr) + } + + dbPath := filepath.Join(repo, ".git", "acd", "state.db") + for _, name := range files { + waitForEventState(t, dbPath, name, "published", 25*time.Second) + } + if hits.Load() != 1 { + t.Fatalf("planner hits=%d want 1", hits.Load()) + } + seqs, ok := firstOffered.Load().([]int64) + if !ok || len(seqs) != 5 { + t.Fatalf("first offered seqs=%v want 5 seqs", firstOffered.Load()) + } + if got := commitCount(t, repo); got != startCount+1 { + t.Fatalf("commit count=%d want %d (one grouped rapid-five commit)", got, startCount+1) + } + if subj := headSubject(t, repo); subj != "Group rapid five" { + t.Fatalf("subject=%q want grouped rapid-five subject", subj) + } +} + type intentChatRequest struct { Messages []struct { Content string `json:"content"` From b550e71f2b3da64d34e3a2b86c691bea5fc924de Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:18:24 +0300 Subject: [PATCH 14/42] Adjust intent replay planner defaults - Keep planner-driven replays from auto-zeroing the settle window. - Make same-path coalescing opt-in so durable captures stay visible. --- internal/daemon/replay.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/daemon/replay.go b/internal/daemon/replay.go index 634b7d19..b3354577 100644 --- a/internal/daemon/replay.go +++ b/internal/daemon/replay.go @@ -195,8 +195,8 @@ type ReplayOpts struct { IntentMinPending int // IntentSettleWindow is the burst-settle delay after IntentMinPending is - // reached. Zero resolves from env; use a negative value in tests to force - // zero/off. + // reached. Zero resolves from env for normal callers. Tests that inject + // IntentPlanner must set this explicitly to exercise the settle gate. IntentSettleWindow time.Duration // IntentMaxPendingAge is the bounded wait escape hatch for sparse pending @@ -858,6 +858,8 @@ func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), err out.settleWindow = opts.IntentSettleWindow } else if opts.IntentSettleWindow < 0 { out.settleWindow = 0 + } else if opts.IntentPlanner != nil { + out.settleWindow = 0 } if opts.IntentMaxPendingAge > 0 { out.maxPendingAge = opts.IntentMaxPendingAge @@ -1445,9 +1447,9 @@ func buildIntentPlanRequest( forced bool, cfg intentReplayConfig, ) ([]intentReplayItem, ai.IntentPlanRequest, error) { - // Same-path coalesce runs ahead of the planner offer so a burst of - // edits to one file folds into one offered entry. Default ON; opt out - // via ACD_INTENT_PATH_COALESCE=0|false|no|off (restart to apply). + // Legacy same-path coalesce can run ahead of the planner offer, but it + // defaults off so every durable capture remains planner-visible. Operators + // can opt in via ACD_INTENT_PATH_COALESCE=1|true|yes|on (restart to apply). offers, err := coalesceIntentWindow(ctx, db, events, pathCoalesceEnabled(), state.LoadCaptureOps) if err != nil { return nil, ai.IntentPlanRequest{}, err From 59a5050c0baaafce4504eb2be48ce612b4087353 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:22:13 +0300 Subject: [PATCH 15/42] Tighten settle-window replay gating - Skip settle waiting once pending hits the window cap. - Disable the settle window in the singleton replay test. --- internal/daemon/replay.go | 3 +++ test/integration/intent_planner_normalization_test.go | 1 + 2 files changed, 4 insertions(+) diff --git a/internal/daemon/replay.go b/internal/daemon/replay.go index b3354577..0b3e6f52 100644 --- a/internal/daemon/replay.go +++ b/internal/daemon/replay.go @@ -1373,6 +1373,9 @@ func intentBatchWaitReason(pending []state.CaptureEvent, cfg intentReplayConfig, if len(pending) == 0 || cfg.settleWindow <= 0 { return "" } + if cfg.window > 0 && len(pending) >= cfg.window { + return "" + } oldest := pending[0] oldestAge := now.Sub(time.Unix(0, int64(oldest.CapturedTS*float64(time.Second)))) if oldestAge >= cfg.maxPendingAge { diff --git a/test/integration/intent_planner_normalization_test.go b/test/integration/intent_planner_normalization_test.go index f8dbe03a..c954078c 100644 --- a/test/integration/intent_planner_normalization_test.go +++ b/test/integration/intent_planner_normalization_test.go @@ -365,6 +365,7 @@ func TestIntentStrategy_SingletonShortCircuitUsesMessageProvider(t *testing.T) { "ACD_COMMIT_STRATEGY=intent", "ACD_INTENT_WINDOW=10", "ACD_INTENT_MIN_PENDING=1", + "ACD_INTENT_SETTLE_WINDOW=0", "ACD_INTENT_MAX_PENDING_AGE=1h", "ACD_AI_PROVIDER=openai-compat", "ACD_AI_BASE_URL=" + server.URL, From 6019dbe59c1c12769d118e6265d280fe6911a47b Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:22:34 +0300 Subject: [PATCH 16/42] Bypass settle window for full pending batches - Allow intent replay to skip settle delay once the window is full. - Keep CLI observability aligned with the new full-window gate. --- internal/cli/intent_observability.go | 1 + internal/daemon/replay_test.go | 48 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/internal/cli/intent_observability.go b/internal/cli/intent_observability.go index 8cf2a298..d04a5b37 100644 --- a/internal/cli/intent_observability.go +++ b/internal/cli/intent_observability.go @@ -751,6 +751,7 @@ FROM visible_pending`, state.EventStateBlockedConflict, state.EventStateFailed, } else if report.Active && report.ForcedAgingReady == 0 && report.VisiblePendingEvents >= report.MinPending && + (report.Window <= 0 || report.VisiblePendingEvents < report.Window) && report.SettleWindowSeconds > 0 && report.OldestPendingAgeSeconds < report.MaxPendingAgeSeconds && report.NewestPendingAgeSeconds < report.SettleWindowSeconds { diff --git a/internal/daemon/replay_test.go b/internal/daemon/replay_test.go index 232b3279..b2448453 100644 --- a/internal/daemon/replay_test.go +++ b/internal/daemon/replay_test.go @@ -2574,6 +2574,54 @@ func TestReplay_IntentStrategySettleWindowKeepsRapidFiveTogether(t *testing.T) { } } +func TestReplay_IntentStrategyFullWindowBypassesSettleWindow(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + + if _, err := BootstrapShadow(ctx, f.dir, f.db, f.cctx); err != nil { + t.Fatalf("BootstrapShadow: %v", err) + } + captureOnePendingFile(t, ctx, f, "full-a.txt", "a\n") + captureOnePendingFile(t, ctx, f, "full-b.txt", "b\n") + pending, err := state.PendingEvents(ctx, f.db, 0) + if err != nil { + t.Fatalf("PendingEvents: %v", err) + } + if len(pending) != 2 { + t.Fatalf("pending=%d want 2", len(pending)) + } + planner := &recordingIntentPlanner{ + plan: ai.IntentPlan{ + SelectedSeqs: []int64{pending[0].Seq, pending[1].Seq}, + Subject: "Full window", + GroupingReason: "full window bypasses settle wait", + }, + } + sum, err := Replay(ctx, f.dir, f.db, f.cctx, ReplayOpts{ + GitDir: f.gitDir, + CommitStrategy: ai.CommitStrategyIntent, + IntentPlanner: planner, + IntentWindow: 2, + IntentMinPending: 2, + IntentSettleWindow: time.Hour, + IntentMaxPendingAge: 2 * time.Hour, + }) + if err != nil { + t.Fatalf("Replay: %v", err) + } + if sum.Published != 2 || sum.Skipped { + t.Fatalf("summary=%+v want full window to plan without settle wait", sum) + } + if planner.calls != 1 { + t.Fatalf("planner calls=%d want 1", planner.calls) + } + gotOffered := offeredCaptureSeqs(planner.requests[0]) + wantOffered := []int64{pending[0].Seq, pending[1].Seq} + if !reflect.DeepEqual(gotOffered, wantOffered) { + t.Fatalf("offered seqs=%v want %v", gotOffered, wantOffered) + } +} + func TestReplay_IntentStrategyBatchGateUsesVisiblePendingBeyondReplayLimit(t *testing.T) { f := newCaptureFixture(t) ctx := context.Background() From cf5094512b5a0224de8c8653cfd55c7bab88c91e Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:26:38 +0300 Subject: [PATCH 17/42] Add commit group support to intent planning - Add structured commit groups to intent plans and validate them - Normalize grouped grouping reasons alongside legacy plan fields - Apply message-quality rewriting to each commit group independently --- internal/ai/intent_planner.go | 174 ++++++++++++++++++++++++++++------ internal/ai/provider.go | 17 ++++ 2 files changed, 162 insertions(+), 29 deletions(-) diff --git a/internal/ai/intent_planner.go b/internal/ai/intent_planner.go index ec64b264..14881a75 100644 --- a/internal/ai/intent_planner.go +++ b/internal/ai/intent_planner.go @@ -202,6 +202,16 @@ type DeferredReason struct { Reason string `json:"reason"` } +// IntentCommitGroup is one ordered commit inside a partitioned intent plan. +// Legacy plans leave CommitGroups empty and use IntentPlan's top-level +// SelectedSeqs/Subject/Body/GroupingReason as a single group. +type IntentCommitGroup struct { + SelectedSeqs []int64 `json:"selected_seqs"` + Subject string `json:"subject"` + Body string `json:"body,omitempty"` + GroupingReason string `json:"grouping_reason"` +} + // IntentPlan is the structured planner response. Every offered capture seq must // appear in exactly one of SelectedSeqs or DeferredSeqs. type IntentPlan struct { @@ -211,6 +221,7 @@ type IntentPlan struct { Body string `json:"body,omitempty"` GroupingReason string `json:"grouping_reason"` DeferredReasons []DeferredReason `json:"deferred_reasons,omitempty"` + CommitGroups []IntentCommitGroup `json:"commit_groups,omitempty"` Source string `json:"-"` MessageQuality MessageQualityAction `json:"-"` MessageQualityReason string `json:"-"` @@ -300,6 +311,9 @@ const IntentReasonCap = 512 // control characters, and caps them to a bounded size for diagnostics. func NormalizeIntentPlanReasons(plan IntentPlan) IntentPlan { plan.GroupingReason = NormalizeIntentReason(plan.GroupingReason) + for i := range plan.CommitGroups { + plan.CommitGroups[i].GroupingReason = NormalizeIntentReason(plan.CommitGroups[i].GroupingReason) + } for i := range plan.DeferredReasons { plan.DeferredReasons[i].Reason = NormalizeIntentReason(plan.DeferredReasons[i].Reason) } @@ -380,25 +394,6 @@ func (e *IntentPlanValidationError) Error() string { return e.Message } // field classifies the failure for telemetry and future provider-side // normalization. func ValidateIntentPlan(req IntentPlanRequest, plan IntentPlan) error { - if len(plan.SelectedSeqs) == 0 { - return &IntentPlanValidationError{ - Code: IntentPlanValidationShape, - Message: "intent planner: selected_seqs must be non-empty", - } - } - if strings.TrimSpace(plan.Subject) == "" { - return &IntentPlanValidationError{ - Code: IntentPlanValidationShape, - Message: "intent planner: subject must be non-empty", - } - } - if strings.TrimSpace(plan.GroupingReason) == "" { - return &IntentPlanValidationError{ - Code: IntentPlanValidationShape, - Message: "intent planner: grouping_reason must be non-empty", - } - } - offered := make(map[int64]struct{}, len(req.OfferedCaptures)) for _, capture := range req.OfferedCaptures { if capture.Seq == 0 { @@ -417,23 +412,74 @@ func ValidateIntentPlan(req IntentPlanRequest, plan IntentPlan) error { offered[capture.Seq] = struct{}{} } - selected := make(map[int64]struct{}, len(plan.SelectedSeqs)) - for _, seq := range plan.SelectedSeqs { - if _, ok := offered[seq]; !ok { + groups, err := IntentPlanCommitGroups(plan) + if err != nil { + return err + } + selected := make(map[int64]struct{}, len(groups)) + var selectedSeqs []int64 + lastGroupFirstSeq := int64(0) + for groupIndex, group := range groups { + if len(group.SelectedSeqs) == 0 { return &IntentPlanValidationError{ - Code: IntentPlanValidationOfferedWindow, - Seq: seq, - Message: fmt.Sprintf("intent planner: selected seq %d outside offered window", seq), + Code: IntentPlanValidationShape, + Message: fmt.Sprintf("intent planner: commit_groups[%d].selected_seqs must be non-empty", groupIndex), } } - if _, exists := selected[seq]; exists { + if strings.TrimSpace(group.Subject) == "" { return &IntentPlanValidationError{ Code: IntentPlanValidationShape, - Seq: seq, - Message: fmt.Sprintf("intent planner: duplicate selected seq %d", seq), + Message: fmt.Sprintf("intent planner: commit_groups[%d].subject must be non-empty", groupIndex), + } + } + if strings.TrimSpace(group.GroupingReason) == "" { + return &IntentPlanValidationError{ + Code: IntentPlanValidationShape, + Message: fmt.Sprintf("intent planner: commit_groups[%d].grouping_reason must be non-empty", groupIndex), + } + } + groupFirstSeq := group.SelectedSeqs[0] + if lastGroupFirstSeq != 0 && groupFirstSeq < lastGroupFirstSeq { + return &IntentPlanValidationError{ + Code: IntentPlanValidationShape, + Seq: groupFirstSeq, + Message: "intent planner: commit_groups must be ordered by selected seq", } } - selected[seq] = struct{}{} + lastGroupFirstSeq = groupFirstSeq + lastSeq := int64(0) + for _, seq := range group.SelectedSeqs { + if lastSeq != 0 && seq < lastSeq { + return &IntentPlanValidationError{ + Code: IntentPlanValidationShape, + Seq: seq, + Message: "intent planner: selected seqs within a commit group must be ordered", + } + } + lastSeq = seq + if _, ok := offered[seq]; !ok { + return &IntentPlanValidationError{ + Code: IntentPlanValidationOfferedWindow, + Seq: seq, + Message: fmt.Sprintf("intent planner: selected seq %d outside offered window", seq), + } + } + if _, exists := selected[seq]; exists { + return &IntentPlanValidationError{ + Code: IntentPlanValidationShape, + Seq: seq, + Message: fmt.Sprintf("intent planner: duplicate selected seq %d", seq), + } + } + selected[seq] = struct{}{} + selectedSeqs = append(selectedSeqs, seq) + } + } + if len(plan.CommitGroups) > 0 && len(plan.SelectedSeqs) > 0 && !sameSeqSet(plan.SelectedSeqs, selectedSeqs) { + return &IntentPlanValidationError{ + Code: IntentPlanValidationShape, + Message: "intent planner: selected_seqs must match commit_groups selected seqs when both are provided", + } } deferred := make(map[int64]struct{}, len(plan.DeferredSeqs)) @@ -525,6 +571,76 @@ func ValidateIntentPlan(req IntentPlanRequest, plan IntentPlan) error { return nil } +// IntentPlanCommitGroups returns the ordered publish groups for a plan. +// Legacy single-group plans are projected into one group. +func IntentPlanCommitGroups(plan IntentPlan) ([]IntentCommitGroup, error) { + if len(plan.CommitGroups) == 0 { + if len(plan.SelectedSeqs) == 0 { + return nil, &IntentPlanValidationError{ + Code: IntentPlanValidationShape, + Message: "intent planner: selected_seqs must be non-empty", + } + } + if strings.TrimSpace(plan.Subject) == "" { + return nil, &IntentPlanValidationError{ + Code: IntentPlanValidationShape, + Message: "intent planner: subject must be non-empty", + } + } + if strings.TrimSpace(plan.GroupingReason) == "" { + return nil, &IntentPlanValidationError{ + Code: IntentPlanValidationShape, + Message: "intent planner: grouping_reason must be non-empty", + } + } + return []IntentCommitGroup{{ + SelectedSeqs: append([]int64(nil), plan.SelectedSeqs...), + Subject: plan.Subject, + Body: plan.Body, + GroupingReason: plan.GroupingReason, + }}, nil + } + groups := make([]IntentCommitGroup, len(plan.CommitGroups)) + for i, group := range plan.CommitGroups { + groups[i] = IntentCommitGroup{ + SelectedSeqs: append([]int64(nil), group.SelectedSeqs...), + Subject: group.Subject, + Body: group.Body, + GroupingReason: group.GroupingReason, + } + } + return groups, nil +} + +// IntentPlanForCommitGroup projects one partition group back into the legacy +// single-group shape used by message-quality and publish helpers. +func IntentPlanForCommitGroup(plan IntentPlan, group IntentCommitGroup) IntentPlan { + out := plan + out.SelectedSeqs = append([]int64(nil), group.SelectedSeqs...) + out.Subject = group.Subject + out.Body = group.Body + out.GroupingReason = group.GroupingReason + out.CommitGroups = nil + return out +} + +func sameSeqSet(a, b []int64) bool { + if len(a) != len(b) { + return false + } + counts := make(map[int64]int, len(a)) + for _, seq := range a { + counts[seq]++ + } + for _, seq := range b { + counts[seq]-- + if counts[seq] < 0 { + return false + } + } + return true +} + // IntentPlanReasonMarker is the synthesized reason text used by // NormalizeIntentPlanDeferredReasons when a deferred seq has no planner- // supplied reason. Persisting a fixed marker into decision_records.reason diff --git a/internal/ai/provider.go b/internal/ai/provider.go index cdd99cf0..dcb55b99 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -298,6 +298,23 @@ func (c *composed) runPrimaryWithRetry(ctx context.Context, primary IntentPlanne } func applyIntentMessageQuality(ctx context.Context, provider Provider, req IntentPlanRequest, plan IntentPlan) (IntentPlan, error) { + if len(plan.CommitGroups) > 0 { + out := plan + for i, group := range plan.CommitGroups { + groupPlan := IntentPlanForCommitGroup(plan, group) + checked, err := applyIntentMessageQuality(ctx, provider, req, groupPlan) + if err != nil { + return IntentPlan{}, err + } + out.CommitGroups[i].Subject = checked.Subject + out.CommitGroups[i].Body = checked.Body + if checked.MessageQuality != "" { + out.MessageQuality = checked.MessageQuality + out.MessageQualityReason = checked.MessageQualityReason + } + } + return out, nil + } report := EvaluateIntentPlanMessageQuality(req, plan) switch report.Action { case MessageQualityClean: From 839c4d25a064eff0bbf0eb1162c91cce371c8057 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:27:02 +0300 Subject: [PATCH 18/42] Add commit group support to intent planning - Allow one planning response to represent multiple independent commits - Keep legacy top-level fields aligned with the first group for compatibility --- internal/ai/openai_compat.go | 28 ++++++++++++++++++++++++++++ internal/ai/prompt.go | 2 ++ 2 files changed, 30 insertions(+) diff --git a/internal/ai/openai_compat.go b/internal/ai/openai_compat.go index 4b542fc2..c55711f9 100644 --- a/internal/ai/openai_compat.go +++ b/internal/ai/openai_compat.go @@ -146,6 +146,34 @@ func openAIIntentPlanParameters(format CommitFormat) map[string]any { "additionalProperties": false, }, }, + "commit_groups": map[string]any{ + "type": "array", + "description": "Optional ordered commit partition for selected captures. Use when one offered window contains multiple independent commits.", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "selected_seqs": map[string]any{ + "type": "array", + "description": "Non-empty seqs selected for this commit group.", + "items": map[string]any{"type": "integer"}, + }, + "subject": map[string]any{ + "type": "string", + "description": subjectDescription, + }, + "body": map[string]any{ + "type": "string", + "description": "Optional final commit body bullets for why/context/impact.", + }, + "grouping_reason": map[string]any{ + "type": "string", + "description": "Evidence-grounded rationale for why this group is one commit.", + }, + }, + "required": []string{"selected_seqs", "subject", "body", "grouping_reason"}, + "additionalProperties": false, + }, + }, }, "required": []string{"selected_seqs", "deferred_seqs", "subject", "body", "grouping_reason", "deferred_reasons"}, "additionalProperties": false, diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go index 64c81555..08fa24f7 100644 --- a/internal/ai/prompt.go +++ b/internal/ai/prompt.go @@ -101,6 +101,8 @@ const intentPlannerSystemPrompt = "You are an intent planner for git commits. " "Do not group unrelated captures. " + "Do not invent intent beyond the supplied evidence. " + "Forced-aging windows contain only the overdue capture; when forced_aging is true, select that single offered capture and leave deferred_seqs and deferred_reasons empty. " + + "When one visible window contains multiple independent commit intents, return commit_groups in chronological order; each group has its own selected_seqs, subject, body, and grouping_reason. " + + "When commit_groups is present, top-level selected_seqs must be the union of all group selected_seqs and top-level subject/body/grouping_reason may describe the first group for legacy compatibility. " + "Same-path causality: when you defer an offered seq for path P, every later offered seq that touches P must also be deferred (or the entire same-path chain must be selected together); never split a same-path chain by selecting a later seq while deferring an earlier one. " + "Defer_count guidance: prefer captures whose defer_count >= 1 for inclusion when the evidence permits, so a capture deferred in earlier windows does not churn forever. " + "selected_seqs and deferred_seqs MUST be disjoint and their union MUST equal offered_seqs. " + From 33a7067fbe65c9c718a00c3fa5e8ce891a605ac9 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:27:19 +0300 Subject: [PATCH 19/42] Add commit group support to intent planning - Pass commit-group data from the planner subprocess to the daemon. - Replay and validation now operate on grouped selections instead of a single flat selection, preserving commit boundaries during publishing. --- internal/ai/plugin_subprocess.go | 2 ++ internal/daemon/replay.go | 45 +++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/internal/ai/plugin_subprocess.go b/internal/ai/plugin_subprocess.go index 35fa64de..a9054a95 100644 --- a/internal/ai/plugin_subprocess.go +++ b/internal/ai/plugin_subprocess.go @@ -337,6 +337,7 @@ func (p *SubprocessProvider) PlanIntent(ctx context.Context, plannerReq IntentPl Body: resp.Body, GroupingReason: resp.GroupingReason, DeferredReasons: resp.DeferredReasons, + CommitGroups: resp.CommitGroups, Source: p.Name(), } if strings.TrimSpace(plan.Subject) == "" { @@ -661,6 +662,7 @@ type subprocessResponse struct { DeferredSeqs []int64 `json:"deferred_seqs,omitempty"` GroupingReason string `json:"grouping_reason,omitempty"` DeferredReasons []DeferredReason `json:"deferred_reasons,omitempty"` + CommitGroups []IntentCommitGroup `json:"commit_groups,omitempty"` } // pluginRequest packages a request with its reply channel. The owner diff --git a/internal/daemon/replay.go b/internal/daemon/replay.go index 0b3e6f52..df682f5a 100644 --- a/internal/daemon/replay.go +++ b/internal/daemon/replay.go @@ -1148,14 +1148,39 @@ func replayIntentBatch( return sum, err } - selected, err := selectedIntentItems(items, plan.SelectedSeqs) + groups, err := ai.IntentPlanCommitGroups(plan) if err != nil { return sum, err } - sort.Slice(selected, func(i, j int) bool { - return selected[i].event.Seq < selected[j].event.Seq - }) - return publishIntentSelection(ctx, repoRoot, db, activeCtx, opts, indexFile, selected, plan, parent, parentTree, sum) + currentParent := parent + currentParentTree := parentTree + for _, group := range groups { + groupPlan := ai.IntentPlanForCommitGroup(plan, group) + selected, err := selectedIntentItems(items, group.SelectedSeqs) + if err != nil { + return sum, err + } + sort.Slice(selected, func(i, j int) bool { + return selected[i].event.Seq < selected[j].event.Seq + }) + before := sum + sum, err = publishIntentSelection(ctx, repoRoot, db, activeCtx, opts, indexFile, selected, groupPlan, currentParent, currentParentTree, sum) + if err != nil { + return sum, err + } + if sum.Conflicts > before.Conflicts || sum.Failed > before.Failed || sum.Published == before.Published { + return sum, nil + } + if sum.BaseHead == "" || sum.BaseHead == currentParent { + return sum, nil + } + currentParent = sum.BaseHead + currentParentTree, err = resolveTreeOID(ctx, repoRoot, currentParent) + if err != nil { + return sum, err + } + } + return sum, nil } func rejectInvalidIntentWindowEvents( @@ -1969,8 +1994,14 @@ func recordIntentPromptFallback(ctx context.Context, planner ai.IntentPlanner, r func validateIntentSelectionSafety(items []intentReplayItem, plan ai.IntentPlan) error { selected := map[int64]struct{}{} - for _, seq := range plan.SelectedSeqs { - selected[seq] = struct{}{} + groups, err := ai.IntentPlanCommitGroups(plan) + if err != nil { + return err + } + for _, group := range groups { + for _, seq := range group.SelectedSeqs { + selected[seq] = struct{}{} + } } for i, item := range items { if _, ok := selected[item.event.Seq]; !ok { From 6bf791016ae242e94c2931e19fb1f90e6767d581 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:27:50 +0300 Subject: [PATCH 20/42] Update intentTraceCommitGroups --- internal/daemon/replay.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/daemon/replay.go b/internal/daemon/replay.go index df682f5a..0801df5f 100644 --- a/internal/daemon/replay.go +++ b/internal/daemon/replay.go @@ -2278,6 +2278,7 @@ func traceIntentPlannerOutput(logger acdtrace.Logger, repoRoot string, cctx Capt "offered_seqs": intentItemSeqs(items), }, Output: map[string]any{ + "commit_groups": intentTraceCommitGroups(plan), "selected_seqs": plan.SelectedSeqs, "deferred_seqs": plan.DeferredSeqs, "source": plan.Source, @@ -2286,6 +2287,23 @@ func traceIntentPlannerOutput(logger acdtrace.Logger, repoRoot string, cctx Capt }) } +func intentTraceCommitGroups(plan ai.IntentPlan) []map[string]any { + groups, err := ai.IntentPlanCommitGroups(plan) + if err != nil { + return nil + } + out := make([]map[string]any, 0, len(groups)) + for _, group := range groups { + out = append(out, map[string]any{ + "selected_seqs": append([]int64(nil), group.SelectedSeqs...), + "subject": group.Subject, + "grouping_reason": group.GroupingReason, + "body_present": strings.TrimSpace(group.Body) != "", + }) + } + return out +} + func traceIntentSingletonShortCircuit(logger acdtrace.Logger, repoRoot string, cctx CaptureContext, item intentReplayItem, plan ai.IntentPlan) { if logger == nil { return From eb3be62284df249083e60489f6887fdb7c3cc8af Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:28:26 +0300 Subject: [PATCH 21/42] Update TestValidateIntentPlanAcceptsCommitGroups --- internal/ai/intent_planner_test.go | 56 ++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/internal/ai/intent_planner_test.go b/internal/ai/intent_planner_test.go index dabc7b06..dbee6afd 100644 --- a/internal/ai/intent_planner_test.go +++ b/internal/ai/intent_planner_test.go @@ -300,6 +300,62 @@ func TestValidateIntentPlanRejectsInvalidShapes(t *testing.T) { } } +func TestValidateIntentPlanAcceptsCommitGroups(t *testing.T) { + req := sampleIntentPlanRequest(t) + plan := IntentPlan{ + DeferredSeqs: []int64{102}, + DeferredReasons: []DeferredReason{{ + Seq: 102, + Reason: "documentation belongs later", + }}, + CommitGroups: []IntentCommitGroup{{ + SelectedSeqs: []int64{101}, + Subject: "Update checkout validation", + GroupingReason: "code capture is one focused commit", + }}, + } + if err := ValidateIntentPlan(req, plan); err != nil { + t.Fatalf("ValidateIntentPlan partition: %v", err) + } + groups, err := IntentPlanCommitGroups(plan) + if err != nil { + t.Fatalf("IntentPlanCommitGroups: %v", err) + } + if len(groups) != 1 || groups[0].SelectedSeqs[0] != 101 { + t.Fatalf("groups=%+v want one group selecting 101", groups) + } +} + +func TestValidateIntentPlanRejectsUnorderedCommitGroups(t *testing.T) { + req := sampleIntentPlanRequest(t) + req.OfferedCaptures = append(req.OfferedCaptures, OfferedCapture{ + Seq: 103, + Path: "docs/checkout.md", + Op: "modify", + Timestamp: req.OfferedCaptures[1].Timestamp, + Fidelity: "exact", + }) + plan := IntentPlan{ + CommitGroups: []IntentCommitGroup{ + {SelectedSeqs: []int64{103}, Subject: "Update docs", GroupingReason: "docs first"}, + {SelectedSeqs: []int64{101}, Subject: "Update checkout", GroupingReason: "code second"}, + }, + DeferredSeqs: []int64{102}, + DeferredReasons: []DeferredReason{{ + Seq: 102, + Reason: "separate later work", + }}, + } + err := ValidateIntentPlan(req, plan) + if err == nil { + t.Fatalf("expected unordered commit_groups rejection") + } + var typed *IntentPlanValidationError + if !errors.As(err, &typed) || typed.Code != IntentPlanValidationShape { + t.Fatalf("error=%T %v want shape validation error", err, err) + } +} + func TestValidateIntentPlanReturnsTypedErrorForSelectedDeferredOverlap(t *testing.T) { req := sampleIntentPlanRequest(t) plan := IntentPlan{ From 201bc503e532c2f827fbc126daba6b0db5192fb6 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:29:01 +0300 Subject: [PATCH 22/42] =?UTF-8?q?Update=20TestReplay=5FIntentStrategyPubli?= =?UTF-8?q?shesPartitio=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/daemon/replay_test.go | 68 ++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/internal/daemon/replay_test.go b/internal/daemon/replay_test.go index b2448453..36556283 100644 --- a/internal/daemon/replay_test.go +++ b/internal/daemon/replay_test.go @@ -3159,6 +3159,74 @@ func TestReplay_IntentStrategyPublishesSelectedCapturesAsOneCommit(t *testing.T) assertReplayDecision(t, ctx, f.db, pending[1].Seq, state.DecisionKindCommitted, "intent_group: same user intent") } +func TestReplay_IntentStrategyPublishesPartitionGroupsSequentially(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + + if _, err := BootstrapShadow(ctx, f.dir, f.db, f.cctx); err != nil { + t.Fatalf("BootstrapShadow: %v", err) + } + captureOnePendingFile(t, ctx, f, "partition-a.txt", "a\n") + captureOnePendingFile(t, ctx, f, "partition-b.txt", "b\n") + captureOnePendingFile(t, ctx, f, "partition-c.txt", "c\n") + pending, err := state.PendingEvents(ctx, f.db, 0) + if err != nil { + t.Fatalf("PendingEvents: %v", err) + } + if len(pending) != 3 { + t.Fatalf("pending=%d want 3", len(pending)) + } + planner := &recordingIntentPlanner{ + plan: ai.IntentPlan{ + DeferredSeqs: []int64{pending[2].Seq}, + DeferredReasons: []ai.DeferredReason{{ + Seq: pending[2].Seq, + Reason: "third capture is unrelated", + }}, + CommitGroups: []ai.IntentCommitGroup{ + { + SelectedSeqs: []int64{pending[0].Seq}, + Subject: "Add partition A", + GroupingReason: "first capture is one intent", + }, + { + SelectedSeqs: []int64{pending[1].Seq}, + Subject: "Add partition B", + GroupingReason: "second capture is independent", + }, + }, + }, + } + + sum, err := Replay(ctx, f.dir, f.db, f.cctx, ReplayOpts{ + GitDir: f.gitDir, + CommitStrategy: ai.CommitStrategyIntent, + IntentPlanner: planner, + IntentWindow: 10, + IntentMinPending: 3, + }) + if err != nil { + t.Fatalf("Replay: %v", err) + } + if sum.Published != 2 || sum.Conflicts != 0 || sum.Failed != 0 { + t.Fatalf("summary=%+v want 2 published partition events", sum) + } + if got := revListCount(t, ctx, f.dir, "HEAD"); got != 3 { + t.Fatalf("commit count=%d want seed+2 partition commits", got) + } + oidA := captureCommitOID(t, ctx, f.db, pending[0].Seq) + oidB := captureCommitOID(t, ctx, f.db, pending[1].Seq) + if oidA == "" || oidB == "" || oidA == oidB { + t.Fatalf("partition commit oids A=%q B=%q want distinct non-empty commits", oidA, oidB) + } + stateC := captureEventState(t, ctx, f.db, pending[2].Seq) + if stateC != state.EventStatePending { + t.Fatalf("third capture state=%q want pending", stateC) + } + assertReplayDecision(t, ctx, f.db, pending[0].Seq, state.DecisionKindCommitted, "intent_group: first capture is one intent") + assertReplayDecision(t, ctx, f.db, pending[1].Seq, state.DecisionKindCommitted, "intent_group: second capture is independent") +} + func TestReplay_IntentStrategyRecordsDeferralsAndForcesAgingWindow(t *testing.T) { f := newCaptureFixture(t) ctx := context.Background() From b0162df9a6be9ac72dcaec7226aeac7cdcf3f315 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:29:12 +0300 Subject: [PATCH 23/42] Update captureCommitOID --- internal/daemon/replay_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/daemon/replay_test.go b/internal/daemon/replay_test.go index 36556283..78e30a27 100644 --- a/internal/daemon/replay_test.go +++ b/internal/daemon/replay_test.go @@ -4505,6 +4505,27 @@ func assertReplayDecision(t *testing.T, ctx context.Context, db *state.DB, seq i t.Fatalf("missing replay decision kind=%q reason=%q for seq %d: %+v", kind, reason, seq, decisions) } +func captureCommitOID(t *testing.T, ctx context.Context, db *state.DB, seq int64) string { + t.Helper() + var oid sql.NullString + if err := db.SQL().QueryRowContext(ctx, `SELECT commit_oid FROM capture_events WHERE seq = ?`, seq).Scan(&oid); err != nil { + t.Fatalf("capture commit_oid seq=%d: %v", seq, err) + } + if !oid.Valid { + return "" + } + return oid.String +} + +func captureEventState(t *testing.T, ctx context.Context, db *state.DB, seq int64) string { + t.Helper() + var st string + if err := db.SQL().QueryRowContext(ctx, `SELECT state FROM capture_events WHERE seq = ?`, seq).Scan(&st); err != nil { + t.Fatalf("capture state seq=%d: %v", seq, err) + } + return st +} + func assertIntentPlannerErrorDecision(t *testing.T, ctx context.Context, db *state.DB, seq int64, reasonContains string) { t.Helper() decisions, err := state.DecisionsForEvent(ctx, db, seq, 10) From 0dd3d321eef10d166702d6ac1ae934314c3e2281 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:29:18 +0300 Subject: [PATCH 24/42] Update plugin_subprocess.go --- internal/ai/plugin_subprocess.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/ai/plugin_subprocess.go b/internal/ai/plugin_subprocess.go index a9054a95..e05006b0 100644 --- a/internal/ai/plugin_subprocess.go +++ b/internal/ai/plugin_subprocess.go @@ -654,14 +654,14 @@ type subprocessOp struct { // subprocessResponse is the JSONL response envelope. type subprocessResponse struct { - Version int `json:"version"` - Subject string `json:"subject"` - Body string `json:"body"` - Error string `json:"error"` - SelectedSeqs []int64 `json:"selected_seqs,omitempty"` - DeferredSeqs []int64 `json:"deferred_seqs,omitempty"` - GroupingReason string `json:"grouping_reason,omitempty"` - DeferredReasons []DeferredReason `json:"deferred_reasons,omitempty"` + Version int `json:"version"` + Subject string `json:"subject"` + Body string `json:"body"` + Error string `json:"error"` + SelectedSeqs []int64 `json:"selected_seqs,omitempty"` + DeferredSeqs []int64 `json:"deferred_seqs,omitempty"` + GroupingReason string `json:"grouping_reason,omitempty"` + DeferredReasons []DeferredReason `json:"deferred_reasons,omitempty"` CommitGroups []IntentCommitGroup `json:"commit_groups,omitempty"` } From 5d8c852bf8ac291070781925d84984b71fff9b07 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:29:23 +0300 Subject: [PATCH 25/42] Update replay.go --- internal/daemon/replay.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/daemon/replay.go b/internal/daemon/replay.go index 0801df5f..fc9a43d6 100644 --- a/internal/daemon/replay.go +++ b/internal/daemon/replay.go @@ -2278,7 +2278,7 @@ func traceIntentPlannerOutput(logger acdtrace.Logger, repoRoot string, cctx Capt "offered_seqs": intentItemSeqs(items), }, Output: map[string]any{ - "commit_groups": intentTraceCommitGroups(plan), + "commit_groups": intentTraceCommitGroups(plan), "selected_seqs": plan.SelectedSeqs, "deferred_seqs": plan.DeferredSeqs, "source": plan.Source, @@ -2295,10 +2295,10 @@ func intentTraceCommitGroups(plan ai.IntentPlan) []map[string]any { out := make([]map[string]any, 0, len(groups)) for _, group := range groups { out = append(out, map[string]any{ - "selected_seqs": append([]int64(nil), group.SelectedSeqs...), - "subject": group.Subject, - "grouping_reason": group.GroupingReason, - "body_present": strings.TrimSpace(group.Body) != "", + "selected_seqs": append([]int64(nil), group.SelectedSeqs...), + "subject": group.Subject, + "grouping_reason": group.GroupingReason, + "body_present": strings.TrimSpace(group.Body) != "", }) } return out From 66b747ae1855af6902aa180c49fd0e31773218e1 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:31:48 +0300 Subject: [PATCH 26/42] Update intent_planner.go --- internal/ai/intent_planner.go | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/internal/ai/intent_planner.go b/internal/ai/intent_planner.go index 14881a75..b1c50192 100644 --- a/internal/ai/intent_planner.go +++ b/internal/ai/intent_planner.go @@ -438,7 +438,12 @@ func ValidateIntentPlan(req IntentPlanRequest, plan IntentPlan) error { Message: fmt.Sprintf("intent planner: commit_groups[%d].grouping_reason must be non-empty", groupIndex), } } - groupFirstSeq := group.SelectedSeqs[0] + groupFirstSeq := int64(0) + for _, seq := range group.SelectedSeqs { + if groupFirstSeq == 0 || seq < groupFirstSeq { + groupFirstSeq = seq + } + } if lastGroupFirstSeq != 0 && groupFirstSeq < lastGroupFirstSeq { return &IntentPlanValidationError{ Code: IntentPlanValidationShape, @@ -447,16 +452,7 @@ func ValidateIntentPlan(req IntentPlanRequest, plan IntentPlan) error { } } lastGroupFirstSeq = groupFirstSeq - lastSeq := int64(0) for _, seq := range group.SelectedSeqs { - if lastSeq != 0 && seq < lastSeq { - return &IntentPlanValidationError{ - Code: IntentPlanValidationShape, - Seq: seq, - Message: "intent planner: selected seqs within a commit group must be ordered", - } - } - lastSeq = seq if _, ok := offered[seq]; !ok { return &IntentPlanValidationError{ Code: IntentPlanValidationOfferedWindow, From be38b1424f4b16c5379359b5d875b7b7390916ce Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:34:02 +0300 Subject: [PATCH 27/42] Add planner window observability schema - Add v11 state schema support for planner-window observability - Persist offered, hidden, selected, and deferred event breakdowns - Keep migration notes aligned with the new durable storage layout --- internal/state/migrate.go | 4 +++- internal/state/schema.go | 46 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/internal/state/migrate.go b/internal/state/migrate.go index 347155a3..2d519f6e 100644 --- a/internal/state/migrate.go +++ b/internal/state/migrate.go @@ -52,7 +52,9 @@ ALTER TABLE decision_records_v6 RENAME TO decision_records; // bounded intent-planner deferrals. v8 adds reusable rewrite plan storage. // v9 adds rewrite_plans.validation_error for structured proposal failures. // v10 adds rewrite_plans.commit_format so saved plans preserve the validation -// policy used when they were generated or edited. +// policy used when they were generated or edited. v11 adds +// intent_planner_windows and intent_planner_window_events for lossless, +// privacy-safe planner-window observability. // Future migrations are append-only for daily_rollups (D9) — only ALTER TABLE // ADD COLUMN. Schema-changing helpers belong here, not in db.go. // diff --git a/internal/state/schema.go b/internal/state/schema.go index 033209ca..ee455341 100644 --- a/internal/state/schema.go +++ b/internal/state/schema.go @@ -20,8 +20,9 @@ package state // than a foreign key cleared by capture_events pruning; v7 adds planner_state // for bounded intent-planner deferrals; v8 adds reusable rewrite plan storage; // v9 adds structured rewrite proposal failure storage; v10 preserves the -// commit-message format used to validate rewrite plans. -const SchemaVersion = 10 +// commit-message format used to validate rewrite plans; v11 adds durable +// intent planner-window summaries for captured-vs-offered observability. +const SchemaVersion = 11 // schemaDDL is the canonical per-repo state.db schema (§6.1). // @@ -129,6 +130,47 @@ CREATE INDEX IF NOT EXISTS idx_planner_state_defer_count_planned CREATE INDEX IF NOT EXISTS idx_planner_state_last_planned ON planner_state(last_planned_ts, event_seq); +CREATE TABLE IF NOT EXISTS intent_planner_windows( + id INTEGER PRIMARY KEY AUTOINCREMENT, + planned_ts REAL NOT NULL, + provider TEXT, + model TEXT, + branch_ref TEXT NOT NULL, + branch_generation INTEGER NOT NULL, + source TEXT, + commit_format TEXT, + forced INTEGER NOT NULL DEFAULT 0, + forced_reason TEXT, + validation_failure TEXT, + offered_seqs TEXT NOT NULL, + visible_original_seqs TEXT NOT NULL, + hidden_seqs TEXT NOT NULL, + selected_groups TEXT NOT NULL, + deferred_seqs TEXT NOT NULL, + deferred_reasons TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_intent_planner_windows_ts_id + ON intent_planner_windows(planned_ts, id); + +CREATE INDEX IF NOT EXISTS idx_intent_planner_windows_branch_id + ON intent_planner_windows(branch_ref, branch_generation, id); + +CREATE TABLE IF NOT EXISTS intent_planner_window_events( + window_id INTEGER NOT NULL, + event_seq INTEGER NOT NULL, + offered INTEGER NOT NULL DEFAULT 0, + hidden INTEGER NOT NULL DEFAULT 0, + selected INTEGER NOT NULL DEFAULT 0, + deferred INTEGER NOT NULL DEFAULT 0, + group_ord INTEGER, + PRIMARY KEY (window_id, event_seq), + FOREIGN KEY (window_id) REFERENCES intent_planner_windows(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_intent_planner_window_events_seq_window + ON intent_planner_window_events(event_seq, window_id); + CREATE TABLE IF NOT EXISTS rewrite_plans( id TEXT PRIMARY KEY, created_ts REAL NOT NULL, From 73cb412c263e1414322224ba9621dffdb1f6f724 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:34:37 +0300 Subject: [PATCH 28/42] Add planner window observability records - Store privacy-safe planner window summaries and per-event lookup rows. - Add schema coverage to ensure the new tables and indexes exist. --- internal/state/intent_windows.go | 312 +++++++++++++++++++++++++++++++ internal/state/state_test.go | 34 +++- 2 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 internal/state/intent_windows.go diff --git a/internal/state/intent_windows.go b/internal/state/intent_windows.go new file mode 100644 index 00000000..471b088a --- /dev/null +++ b/internal/state/intent_windows.go @@ -0,0 +1,312 @@ +package state + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" +) + +// IntentPlannerWindowGroup is a privacy-safe summary of one commit group +// selected from a planner-visible window. It stores seqs and rationale, never +// raw diffs. +type IntentPlannerWindowGroup struct { + SelectedSeqs []int64 `json:"selected_seqs"` + OriginalSeqs []int64 `json:"original_seqs,omitempty"` + Subject string `json:"subject,omitempty"` + GroupingReason string `json:"grouping_reason,omitempty"` +} + +// IntentPlannerWindowDeferredReason records the planner's explanation for a +// deferred capture seq. +type IntentPlannerWindowDeferredReason struct { + Seq int64 `json:"seq"` + Reason string `json:"reason"` +} + +// IntentPlannerWindowEvent indexes how one original capture seq participated +// in a planner window. Offered is true only for seqs that appeared directly in +// the planner request; Hidden marks seqs covered by legacy same-path coalesce. +type IntentPlannerWindowEvent struct { + EventSeq int64 + Offered bool + Hidden bool + Selected bool + Deferred bool + GroupOrd sql.NullInt64 +} + +// IntentPlannerWindow is the durable, privacy-safe planner observability +// record. JSON fields are arrays of seqs/groups only; prompt traces remain the +// opt-in source for exact prompts and diffs. +type IntentPlannerWindow struct { + ID int64 + PlannedTS float64 + Provider sql.NullString + Model sql.NullString + BranchRef string + BranchGeneration int64 + Source sql.NullString + CommitFormat sql.NullString + Forced bool + ForcedReason sql.NullString + ValidationFailure sql.NullString + OfferedSeqs []int64 + VisibleOriginalSeqs []int64 + HiddenSeqs []int64 + SelectedGroups []IntentPlannerWindowGroup + DeferredSeqs []int64 + DeferredReasons []IntentPlannerWindowDeferredReason + Events []IntentPlannerWindowEvent +} + +// AppendIntentPlannerWindow records one planner-visible window plus a compact +// per-event lookup index in a single transaction. +func AppendIntentPlannerWindow(ctx context.Context, d *DB, win IntentPlannerWindow) (int64, error) { + if win.BranchRef == "" { + return 0, fmt.Errorf("state: AppendIntentPlannerWindow: empty branch_ref") + } + if win.PlannedTS <= 0 { + win.PlannedTS = nowSeconds() + } + offered, err := marshalArray(win.OfferedSeqs) + if err != nil { + return 0, fmt.Errorf("state: marshal offered seqs: %w", err) + } + visible, err := marshalArray(win.VisibleOriginalSeqs) + if err != nil { + return 0, fmt.Errorf("state: marshal visible original seqs: %w", err) + } + hidden, err := marshalArray(win.HiddenSeqs) + if err != nil { + return 0, fmt.Errorf("state: marshal hidden seqs: %w", err) + } + groups, err := marshalArray(win.SelectedGroups) + if err != nil { + return 0, fmt.Errorf("state: marshal selected groups: %w", err) + } + deferred, err := marshalArray(win.DeferredSeqs) + if err != nil { + return 0, fmt.Errorf("state: marshal deferred seqs: %w", err) + } + deferredReasons, err := marshalArray(win.DeferredReasons) + if err != nil { + return 0, fmt.Errorf("state: marshal deferred reasons: %w", err) + } + + tx, err := d.conn.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("state: begin intent planner window tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + const insWindow = ` +INSERT INTO intent_planner_windows( + planned_ts, provider, model, branch_ref, branch_generation, source, + commit_format, forced, forced_reason, validation_failure, offered_seqs, + visible_original_seqs, hidden_seqs, selected_groups, deferred_seqs, + deferred_reasons +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + res, err := tx.ExecContext(ctx, insWindow, + win.PlannedTS, win.Provider, win.Model, win.BranchRef, win.BranchGeneration, + win.Source, win.CommitFormat, boolInt(win.Forced), win.ForcedReason, + win.ValidationFailure, offered, visible, hidden, groups, deferred, + deferredReasons, + ) + if err != nil { + return 0, fmt.Errorf("state: insert intent planner window: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("state: intent planner window id: %w", err) + } + if len(win.Events) > 0 { + const insEvent = ` +INSERT INTO intent_planner_window_events( + window_id, event_seq, offered, hidden, selected, deferred, group_ord +) VALUES (?, ?, ?, ?, ?, ?, ?)` + stmt, err := tx.PrepareContext(ctx, insEvent) + if err != nil { + return 0, fmt.Errorf("state: prepare intent planner window events: %w", err) + } + defer stmt.Close() + for _, ev := range win.Events { + if ev.EventSeq <= 0 { + return 0, fmt.Errorf("state: intent planner window event seq must be positive") + } + if _, err := stmt.ExecContext(ctx, + id, ev.EventSeq, boolInt(ev.Offered), boolInt(ev.Hidden), + boolInt(ev.Selected), boolInt(ev.Deferred), ev.GroupOrd, + ); err != nil { + return 0, fmt.Errorf("state: insert intent planner window event %d: %w", ev.EventSeq, err) + } + } + } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("state: commit intent planner window tx: %w", err) + } + return id, nil +} + +// RecentIntentPlannerWindows returns recent planner-window summaries newest +// first. +func RecentIntentPlannerWindows(ctx context.Context, d *DB, limit int) ([]IntentPlannerWindow, error) { + if limit <= 0 { + limit = 1 + } + rows, err := d.readSQL().QueryContext(ctx, ` +SELECT id, planned_ts, provider, model, branch_ref, branch_generation, source, + commit_format, forced, forced_reason, validation_failure, offered_seqs, + visible_original_seqs, hidden_seqs, selected_groups, deferred_seqs, + deferred_reasons +FROM intent_planner_windows +ORDER BY id DESC +LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("state: query intent planner windows: %w", err) + } + defer rows.Close() + windows, err := scanIntentPlannerWindows(rows) + if err != nil { + return nil, err + } + for i := range windows { + events, err := intentPlannerWindowEvents(ctx, d, windows[i].ID) + if err != nil { + return nil, err + } + windows[i].Events = events + } + return windows, nil +} + +// IntentPlannerWindowForEvent returns the latest planner window that mentions +// eventSeq, if any. +func IntentPlannerWindowForEvent(ctx context.Context, d *DB, eventSeq int64) (IntentPlannerWindow, bool, error) { + if eventSeq <= 0 { + return IntentPlannerWindow{}, false, fmt.Errorf("state: IntentPlannerWindowForEvent: invalid seq %d", eventSeq) + } + rows, err := d.readSQL().QueryContext(ctx, ` +SELECT w.id, w.planned_ts, w.provider, w.model, w.branch_ref, w.branch_generation, + w.source, w.commit_format, w.forced, w.forced_reason, w.validation_failure, + w.offered_seqs, w.visible_original_seqs, w.hidden_seqs, w.selected_groups, + w.deferred_seqs, w.deferred_reasons +FROM intent_planner_windows w +JOIN intent_planner_window_events e ON e.window_id = w.id +WHERE e.event_seq = ? +ORDER BY w.id DESC +LIMIT 1`, eventSeq) + if err != nil { + return IntentPlannerWindow{}, false, fmt.Errorf("state: query intent planner window for event: %w", err) + } + defer rows.Close() + windows, err := scanIntentPlannerWindows(rows) + if err != nil { + return IntentPlannerWindow{}, false, err + } + if len(windows) == 0 { + return IntentPlannerWindow{}, false, nil + } + events, err := intentPlannerWindowEvents(ctx, d, windows[0].ID) + if err != nil { + return IntentPlannerWindow{}, false, err + } + windows[0].Events = events + return windows[0], true, nil +} + +func intentPlannerWindowEvents(ctx context.Context, d *DB, windowID int64) ([]IntentPlannerWindowEvent, error) { + rows, err := d.readSQL().QueryContext(ctx, ` +SELECT event_seq, offered, hidden, selected, deferred, group_ord +FROM intent_planner_window_events +WHERE window_id = ? +ORDER BY event_seq ASC`, windowID) + if err != nil { + return nil, fmt.Errorf("state: query intent planner window events: %w", err) + } + defer rows.Close() + var out []IntentPlannerWindowEvent + for rows.Next() { + var ev IntentPlannerWindowEvent + var offered, hidden, selected, deferred int + if err := rows.Scan(&ev.EventSeq, &offered, &hidden, &selected, &deferred, &ev.GroupOrd); err != nil { + return nil, fmt.Errorf("state: scan intent planner window event: %w", err) + } + ev.Offered = offered != 0 + ev.Hidden = hidden != 0 + ev.Selected = selected != 0 + ev.Deferred = deferred != 0 + out = append(out, ev) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("state: iter intent planner window events: %w", err) + } + return out, nil +} + +func scanIntentPlannerWindows(rows *sql.Rows) ([]IntentPlannerWindow, error) { + var out []IntentPlannerWindow + for rows.Next() { + var win IntentPlannerWindow + var forced int + var offered, visible, hidden, groups, deferred, deferredReasons string + if err := rows.Scan( + &win.ID, &win.PlannedTS, &win.Provider, &win.Model, + &win.BranchRef, &win.BranchGeneration, &win.Source, + &win.CommitFormat, &forced, &win.ForcedReason, + &win.ValidationFailure, &offered, &visible, &hidden, &groups, + &deferred, &deferredReasons, + ); err != nil { + return nil, fmt.Errorf("state: scan intent planner window: %w", err) + } + win.Forced = forced != 0 + if err := unmarshalArray(offered, &win.OfferedSeqs); err != nil { + return nil, fmt.Errorf("state: unmarshal offered seqs: %w", err) + } + if err := unmarshalArray(visible, &win.VisibleOriginalSeqs); err != nil { + return nil, fmt.Errorf("state: unmarshal visible original seqs: %w", err) + } + if err := unmarshalArray(hidden, &win.HiddenSeqs); err != nil { + return nil, fmt.Errorf("state: unmarshal hidden seqs: %w", err) + } + if err := unmarshalArray(groups, &win.SelectedGroups); err != nil { + return nil, fmt.Errorf("state: unmarshal selected groups: %w", err) + } + if err := unmarshalArray(deferred, &win.DeferredSeqs); err != nil { + return nil, fmt.Errorf("state: unmarshal deferred seqs: %w", err) + } + if err := unmarshalArray(deferredReasons, &win.DeferredReasons); err != nil { + return nil, fmt.Errorf("state: unmarshal deferred reasons: %w", err) + } + out = append(out, win) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("state: iter intent planner windows: %w", err) + } + return out, nil +} + +func marshalArray(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + if string(b) == "null" { + return "[]", nil + } + return string(b), nil +} + +func unmarshalArray(raw string, v any) error { + if raw == "" { + raw = "[]" + } + return json.Unmarshal([]byte(raw), v) +} + +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go index b5a854da..42790f38 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -82,9 +82,10 @@ func TestOpenCreatesSchemaAndPragmas(t *testing.T) { // Confirm every §6.1 table exists. tables := []string{ "daemon_state", "daemon_clients", "shadow_paths", - "capture_events", "capture_ops", "planner_state", "rewrite_plans", - "rewrite_plan_commits", "flush_requests", "decision_records", "publish_state", - "daemon_meta", "daily_rollups", + "capture_events", "capture_ops", "planner_state", "intent_planner_windows", + "intent_planner_window_events", "rewrite_plans", "rewrite_plan_commits", + "flush_requests", "decision_records", "publish_state", "daemon_meta", + "daily_rollups", } for _, table := range tables { var name string @@ -753,6 +754,33 @@ func TestPlannerStateIndexesExist(t *testing.T) { } } +func TestIntentPlannerWindowIndexesExist(t *testing.T) { + t.Parallel() + d, _ := openTestDB(t) + ctx := context.Background() + + checks := []struct { + table string + index string + }{ + {"intent_planner_windows", "idx_intent_planner_windows_ts_id"}, + {"intent_planner_windows", "idx_intent_planner_windows_branch_id"}, + {"intent_planner_window_events", "idx_intent_planner_window_events_seq_window"}, + } + for _, check := range checks { + var name string + err := d.SQL().QueryRowContext(ctx, + `SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name = ?`, + check.table, check.index).Scan(&name) + if err != nil { + t.Fatalf("%s index %q missing: %v", check.table, check.index, err) + } + if name != check.index { + t.Fatalf("%s index name=%q want %q", check.table, name, check.index) + } + } +} + func TestPendingEventsStopsAfterTerminalPredecessor(t *testing.T) { t.Parallel() d, _ := openTestDB(t) From 6a4ab564c423feaa700a7c4db24d6cff7714d6b4 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:35:39 +0300 Subject: [PATCH 29/42] Add planner window metadata persistence - Record planner window metadata end to end. - Persist and reload window fields so observability data survives storage. - Capture planner provider/model from replay config for window records. --- internal/daemon/replay.go | 9 +++ internal/state/intent_windows_test.go | 90 +++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 internal/state/intent_windows_test.go diff --git a/internal/daemon/replay.go b/internal/daemon/replay.go index fc9a43d6..f457a94b 100644 --- a/internal/daemon/replay.go +++ b/internal/daemon/replay.go @@ -818,6 +818,8 @@ type intentReplayConfig struct { // HEAD commit landed within the window. recentCommitAffinity time.Duration commitFormat ai.CommitFormat + plannerProvider string + plannerModel string } func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), error) { @@ -890,6 +892,7 @@ func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), err if opts.IntentPlanner != nil { out.planner = opts.IntentPlanner + out.plannerProvider = ai.PrimaryProviderName(opts.IntentPlanner) return out, nil, nil } @@ -898,12 +901,14 @@ func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), err if err != nil { slog.Default().Warn("build intent planner; falling back to deterministic", "err", err.Error()) out.planner = ai.DeterministicProvider{CommitFormat: out.commitFormat} + out.plannerProvider = out.planner.Name() return out, nil, nil } planner, ok := provider.(ai.IntentPlanner) if !ok { slog.Default().Warn("AI provider does not implement intent planning; falling back to deterministic", "provider", provider.Name()) out.planner = ai.DeterministicProvider{CommitFormat: out.commitFormat} + out.plannerProvider = out.planner.Name() if closer != nil { return out, func() { if err := closer.Close(); err != nil { @@ -914,6 +919,10 @@ func resolveIntentReplayConfig(opts ReplayOpts) (intentReplayConfig, func(), err return out, nil, nil } out.planner = planner + out.plannerProvider = ai.PrimaryProviderName(planner) + if out.plannerProvider == "openai-compat" { + out.plannerModel = providerCfg.Model + } if ai.ProviderNeedsDiff(provider) && diffEgressOptIn() { out.includeDiffs = true } diff --git a/internal/state/intent_windows_test.go b/internal/state/intent_windows_test.go new file mode 100644 index 00000000..1469b6a0 --- /dev/null +++ b/internal/state/intent_windows_test.go @@ -0,0 +1,90 @@ +package state + +import ( + "context" + "database/sql" + "reflect" + "testing" +) + +func TestIntentPlannerWindowRoundTrip(t *testing.T) { + t.Parallel() + d, _ := openTestDB(t) + ctx := context.Background() + + win := IntentPlannerWindow{ + PlannedTS: 123, + Provider: sql.NullString{String: "openai-compat", Valid: true}, + Model: sql.NullString{String: "gpt-test", Valid: true}, + BranchRef: "refs/heads/main", + BranchGeneration: 7, + Source: sql.NullString{String: "openai-compat", Valid: true}, + CommitFormat: sql.NullString{String: "imperative", Valid: true}, + Forced: true, + ForcedReason: sql.NullString{String: "defer_limit", Valid: true}, + OfferedSeqs: []int64{10, 12}, + VisibleOriginalSeqs: []int64{ + 10, 11, 12, + }, + HiddenSeqs: []int64{11}, + SelectedGroups: []IntentPlannerWindowGroup{{ + SelectedSeqs: []int64{10}, + OriginalSeqs: []int64{10, 11}, + Subject: "Update parser", + GroupingReason: "related parser edits", + }}, + DeferredSeqs: []int64{12}, + DeferredReasons: []IntentPlannerWindowDeferredReason{{ + Seq: 12, + Reason: "separate docs change", + }}, + Events: []IntentPlannerWindowEvent{ + {EventSeq: 10, Offered: true, Selected: true, GroupOrd: sql.NullInt64{Int64: 0, Valid: true}}, + {EventSeq: 11, Hidden: true, Selected: true, GroupOrd: sql.NullInt64{Int64: 0, Valid: true}}, + {EventSeq: 12, Offered: true, Deferred: true}, + }, + } + id, err := AppendIntentPlannerWindow(ctx, d, win) + if err != nil { + t.Fatalf("AppendIntentPlannerWindow: %v", err) + } + if id == 0 { + t.Fatalf("AppendIntentPlannerWindow id = 0") + } + + recent, err := RecentIntentPlannerWindows(ctx, d, 1) + if err != nil { + t.Fatalf("RecentIntentPlannerWindows: %v", err) + } + if len(recent) != 1 { + t.Fatalf("recent windows = %d, want 1", len(recent)) + } + got := recent[0] + if got.ID != id || got.Provider.String != "openai-compat" || got.Model.String != "gpt-test" || + !got.Forced || got.ForcedReason.String != "defer_limit" { + t.Fatalf("window identity fields = %+v", got) + } + if !reflect.DeepEqual(got.OfferedSeqs, []int64{10, 12}) || + !reflect.DeepEqual(got.VisibleOriginalSeqs, []int64{10, 11, 12}) || + !reflect.DeepEqual(got.HiddenSeqs, []int64{11}) || + !reflect.DeepEqual(got.DeferredSeqs, []int64{12}) { + t.Fatalf("window seq fields = %+v", got) + } + if len(got.SelectedGroups) != 1 || + !reflect.DeepEqual(got.SelectedGroups[0].OriginalSeqs, []int64{10, 11}) || + got.SelectedGroups[0].GroupingReason != "related parser edits" { + t.Fatalf("selected groups = %+v", got.SelectedGroups) + } + if len(got.Events) != 3 || !got.Events[1].Hidden || !got.Events[1].Selected || + !got.Events[1].GroupOrd.Valid || got.Events[1].GroupOrd.Int64 != 0 { + t.Fatalf("window events = %+v", got.Events) + } + + forEvent, ok, err := IntentPlannerWindowForEvent(ctx, d, 11) + if err != nil { + t.Fatalf("IntentPlannerWindowForEvent: %v", err) + } + if !ok || forEvent.ID != id || len(forEvent.Events) != 3 { + t.Fatalf("window for event = %+v ok=%v", forEvent, ok) + } +} From 834ba5eb916c75dee30a7fa4a16370d1d55100f0 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:36:09 +0300 Subject: [PATCH 30/42] Record planner windows during replay - Persist planner window metadata for every replay path - Include short-circuits and validation failures in the record - Keep replay history aligned with what the planner saw and chose --- internal/daemon/replay.go | 144 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/internal/daemon/replay.go b/internal/daemon/replay.go index f457a94b..dea2f1ff 100644 --- a/internal/daemon/replay.go +++ b/internal/daemon/replay.go @@ -1085,6 +1085,9 @@ func replayIntentBatch( } } else { traceIntentPlannerOutput(opts.Trace, repoRoot, activeCtx, items, plan) + if err := recordIntentPlannerWindow(ctx, db, cfg, req, plan, items, activeCtx, nowSec, forced, ""); err != nil { + return sum, err + } selected := []intentReplayItem{items[0]} return publishIntentSelection(ctx, repoRoot, db, activeCtx, opts, indexFile, selected, plan, parent, parentTree, sum) } @@ -1117,6 +1120,9 @@ func replayIntentBatch( } } else { traceIntentSingletonShortCircuit(opts.Trace, repoRoot, activeCtx, items[0], plan) + if err := recordIntentPlannerWindow(ctx, db, cfg, req, plan, items, activeCtx, nowSec, forced, ""); err != nil { + return sum, err + } return publishIntentSelection(ctx, repoRoot, db, activeCtx, opts, indexFile, items, plan, parent, parentTree, sum) } cfg.planner = ai.DeterministicProvider{CommitFormat: cfg.commitFormat} @@ -1148,6 +1154,9 @@ func replayIntentBatch( traceIntentPlannerValidationFailure(opts.Trace, repoRoot, activeCtx, items, validationFailure) } traceIntentPlannerOutput(opts.Trace, repoRoot, activeCtx, items, plan) + if err := recordIntentPlannerWindow(ctx, db, cfg, req, plan, items, activeCtx, nowSec, forced, validationFailure); err != nil { + return sum, err + } if plan.MessageQuality != "" { if err := recordIntentMessageQualityDecision(ctx, db, items, activeCtx, nowSec, plan); err != nil { return sum, err @@ -2115,6 +2124,141 @@ func recordIntentDeferrals(ctx context.Context, db *state.DB, plan ai.IntentPlan return nil } +func recordIntentPlannerWindow( + ctx context.Context, + db *state.DB, + cfg intentReplayConfig, + req ai.IntentPlanRequest, + plan ai.IntentPlan, + items []intentReplayItem, + cctx CaptureContext, + ts float64, + forced bool, + validationFailure string, +) error { + groups, err := ai.IntentPlanCommitGroups(plan) + if err != nil { + return err + } + itemBySeq := make(map[int64]intentReplayItem, len(items)) + eventRows := make(map[int64]*state.IntentPlannerWindowEvent, len(items)) + ensureEvent := func(seq int64) *state.IntentPlannerWindowEvent { + row := eventRows[seq] + if row == nil { + row = &state.IntentPlannerWindowEvent{EventSeq: seq} + eventRows[seq] = row + } + return row + } + + var visibleOriginalSeqs []int64 + var hiddenSeqs []int64 + for _, item := range items { + itemBySeq[item.event.Seq] = item + ensureEvent(item.event.Seq).Offered = true + for _, ev := range item.allCoveredEvents() { + visibleOriginalSeqs = appendUniqueSeq(visibleOriginalSeqs, ev.Seq) + row := ensureEvent(ev.Seq) + if ev.Seq != item.event.Seq { + row.Hidden = true + hiddenSeqs = appendUniqueSeq(hiddenSeqs, ev.Seq) + } + } + } + + selectedGroups := make([]state.IntentPlannerWindowGroup, 0, len(groups)) + for i, group := range groups { + summary := state.IntentPlannerWindowGroup{ + SelectedSeqs: append([]int64(nil), group.SelectedSeqs...), + Subject: strings.TrimSpace(group.Subject), + GroupingReason: strings.TrimSpace(group.GroupingReason), + } + for _, seq := range group.SelectedSeqs { + item, ok := itemBySeq[seq] + if !ok { + continue + } + for _, ev := range item.allCoveredEvents() { + summary.OriginalSeqs = appendUniqueSeq(summary.OriginalSeqs, ev.Seq) + row := ensureEvent(ev.Seq) + row.Selected = true + row.GroupOrd = sql.NullInt64{Int64: int64(i), Valid: true} + } + } + selectedGroups = append(selectedGroups, summary) + } + + for _, seq := range plan.DeferredSeqs { + item, ok := itemBySeq[seq] + if !ok { + continue + } + for _, ev := range item.allCoveredEvents() { + ensureEvent(ev.Seq).Deferred = true + } + } + + rows := make([]state.IntentPlannerWindowEvent, 0, len(eventRows)) + for _, row := range eventRows { + rows = append(rows, *row) + } + sort.Slice(rows, func(i, j int) bool { + return rows[i].EventSeq < rows[j].EventSeq + }) + deferredReasons := make([]state.IntentPlannerWindowDeferredReason, 0, len(plan.DeferredReasons)) + for _, reason := range plan.DeferredReasons { + deferredReasons = append(deferredReasons, state.IntentPlannerWindowDeferredReason{ + Seq: reason.Seq, + Reason: strings.TrimSpace(reason.Reason), + }) + } + + provider := strings.TrimSpace(cfg.plannerProvider) + if provider == "" && cfg.planner != nil { + provider = ai.PrimaryProviderName(cfg.planner) + } + if provider == "" { + provider = strings.TrimSpace(plan.Source) + } + model := "" + if provider == "openai-compat" { + model = strings.TrimSpace(cfg.plannerModel) + } + forcedReason := "" + if forced { + forcedReason = "defer_limit" + } + _, err = state.AppendIntentPlannerWindow(ctx, db, state.IntentPlannerWindow{ + PlannedTS: ts, + Provider: nullString(provider), + Model: nullString(model), + BranchRef: cctx.BranchRef, + BranchGeneration: cctx.BranchGeneration, + Source: nullString(strings.TrimSpace(plan.Source)), + CommitFormat: nullString(string(req.CommitFormat)), + Forced: forced, + ForcedReason: nullString(forcedReason), + ValidationFailure: nullString(strings.TrimSpace(validationFailure)), + OfferedSeqs: intentOfferedSeqs(req), + VisibleOriginalSeqs: visibleOriginalSeqs, + HiddenSeqs: hiddenSeqs, + SelectedGroups: selectedGroups, + DeferredSeqs: append([]int64(nil), plan.DeferredSeqs...), + DeferredReasons: deferredReasons, + Events: rows, + }) + return err +} + +func appendUniqueSeq(seqs []int64, seq int64) []int64 { + for _, existing := range seqs { + if existing == seq { + return seqs + } + } + return append(seqs, seq) +} + func recordIntentMessageQualityDecision(ctx context.Context, db *state.DB, items []intentReplayItem, cctx CaptureContext, ts float64, plan ai.IntentPlan) error { if plan.MessageQuality == "" { return nil From b8b5aae55bc25c6aa575d48fae8ce311c73e179b Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:37:13 +0300 Subject: [PATCH 31/42] Expose planner windows in CLI events - Load persisted intent planner windows for replayed events - Surface planner window metadata in event listings - Show offered and hidden seqs to make window output explainable --- internal/cli/events.go | 46 +++++--- internal/cli/intent_windows.go | 202 +++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 internal/cli/intent_windows.go diff --git a/internal/cli/events.go b/internal/cli/events.go index 571240d7..2fcec26a 100644 --- a/internal/cli/events.go +++ b/internal/cli/events.go @@ -57,6 +57,7 @@ type eventEntry struct { GroupedSeqs []int64 `json:"grouped_seqs,omitempty"` GroupSize int `json:"group_size,omitempty"` IntentGroup bool `json:"intent_group,omitempty"` + PlannerWindow *intentPlannerWindowSummary `json:"planner_window,omitempty"` Deferred bool `json:"deferred,omitempty"` ForcedAging bool `json:"forced_aging,omitempty"` PlannerError bool `json:"planner_error,omitempty"` @@ -357,6 +358,13 @@ func renderEventsTable(out io.Writer, entries []eventEntry) error { if len(entry.GroupedSeqs) > 1 { message = fmt.Sprintf("%s seqs=%s", message, formatSeqs(entry.GroupedSeqs)) } + if entry.PlannerWindow != nil { + message = fmt.Sprintf("%s planner_window=%d offered=%s", + message, entry.PlannerWindow.ID, formatSeqs(entry.PlannerWindow.OfferedSeqs)) + if len(entry.PlannerWindow.HiddenSeqs) > 0 { + message = fmt.Sprintf("%s hidden=%s", message, formatSeqs(entry.PlannerWindow.HiddenSeqs)) + } + } fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n", entry.ID, entry.Time, entry.Kind, path, action, message) } @@ -425,26 +433,36 @@ func enrichEventEntries(ctx context.Context, db *sql.DB, entries []eventEntry) e return nil } cache := map[string]decisionCommitSummary{} + hasPlannerWindows, err := sqliteTableExists(ctx, db, "intent_planner_window_events") + if err != nil { + return fmt.Errorf("acd events: intent planner window table check: %w", err) + } for i := range entries { commit := entries[i].CommitOID - if commit == "" { - continue + if commit != "" { + summary, ok := cache[commit] + if !ok { + var err error + summary, err = decisionCommitSummarySQL(ctx, db, commit) + if err != nil { + return err + } + cache[commit] = summary + } + if len(summary.Seqs) > 1 { + entries[i].GroupedSeqs = append([]int64(nil), summary.Seqs...) + entries[i].GroupSize = len(summary.Seqs) + } + if summary.IntentGroup { + entries[i].IntentGroup = true + } } - summary, ok := cache[commit] - if !ok { - var err error - summary, err = decisionCommitSummarySQL(ctx, db, commit) + if hasPlannerWindows && entries[i].EventSeq > 0 { + window, err := loadIntentPlannerWindowForEventSQL(ctx, db, entries[i].EventSeq) if err != nil { return err } - cache[commit] = summary - } - if len(summary.Seqs) > 1 { - entries[i].GroupedSeqs = append([]int64(nil), summary.Seqs...) - entries[i].GroupSize = len(summary.Seqs) - } - if summary.IntentGroup { - entries[i].IntentGroup = true + entries[i].PlannerWindow = window } } return nil diff --git a/internal/cli/intent_windows.go b/internal/cli/intent_windows.go new file mode 100644 index 00000000..2d2fc714 --- /dev/null +++ b/internal/cli/intent_windows.go @@ -0,0 +1,202 @@ +package cli + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/KristjanPikhof/Auto-Commit-Daemon/internal/state" +) + +type intentPlannerWindowSummary struct { + ID int64 `json:"id"` + PlannedTS int64 `json:"planned_ts"` + Time string `json:"time"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + BranchRef string `json:"branch_ref,omitempty"` + BranchGeneration int64 `json:"branch_generation,omitempty"` + Source string `json:"source,omitempty"` + CommitFormat string `json:"commit_format,omitempty"` + Forced bool `json:"forced,omitempty"` + ForcedReason string `json:"forced_reason,omitempty"` + ValidationFailure string `json:"validation_failure,omitempty"` + OfferedSeqs []int64 `json:"offered_seqs,omitempty"` + VisibleOriginalSeqs []int64 `json:"visible_original_seqs,omitempty"` + HiddenSeqs []int64 `json:"hidden_seqs,omitempty"` + SelectedGroups []state.IntentPlannerWindowGroup `json:"selected_groups,omitempty"` + DeferredSeqs []int64 `json:"deferred_seqs,omitempty"` + DeferredReasons []state.IntentPlannerWindowDeferredReason `json:"deferred_reasons,omitempty"` + Event *intentPlannerWindowEventParticipationSummary `json:"event,omitempty"` +} + +type intentPlannerWindowEventParticipationSummary struct { + Seq int64 `json:"seq"` + Offered bool `json:"offered,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Selected bool `json:"selected,omitempty"` + Deferred bool `json:"deferred,omitempty"` + GroupOrd int64 `json:"group_ord,omitempty"` +} + +func loadLastIntentPlannerWindowSQL(ctx context.Context, conn *sql.DB) (*intentPlannerWindowSummary, error) { + ok, err := sqliteTableExists(ctx, conn, "intent_planner_windows") + if err != nil { + return nil, fmt.Errorf("intent planner windows table check: %w", err) + } + if !ok { + return nil, nil + } + rows, err := conn.QueryContext(ctx, intentPlannerWindowSelectSQL+` +FROM intent_planner_windows w +ORDER BY w.id DESC +LIMIT 1`) + if err != nil { + return nil, fmt.Errorf("query last intent planner window: %w", err) + } + defer rows.Close() + windows, err := scanIntentPlannerWindowSummaries(rows) + if err != nil { + return nil, err + } + if len(windows) == 0 { + return nil, nil + } + return &windows[0], nil +} + +func loadIntentPlannerWindowForEventSQL(ctx context.Context, conn *sql.DB, eventSeq int64) (*intentPlannerWindowSummary, error) { + if eventSeq <= 0 { + return nil, nil + } + ok, err := sqliteTableExists(ctx, conn, "intent_planner_window_events") + if err != nil { + return nil, fmt.Errorf("intent planner window events table check: %w", err) + } + if !ok { + return nil, nil + } + rows, err := conn.QueryContext(ctx, intentPlannerWindowSelectSQL+`, + e.offered, e.hidden, e.selected, e.deferred, e.group_ord +FROM intent_planner_windows w +JOIN intent_planner_window_events e ON e.window_id = w.id +WHERE e.event_seq = ? +ORDER BY w.id DESC +LIMIT 1`, eventSeq) + if err != nil { + return nil, fmt.Errorf("query intent planner window for event: %w", err) + } + defer rows.Close() + windows, err := scanIntentPlannerWindowSummariesWithEvent(rows, eventSeq) + if err != nil { + return nil, err + } + if len(windows) == 0 { + return nil, nil + } + return &windows[0], nil +} + +const intentPlannerWindowSelectSQL = ` +SELECT w.id, w.planned_ts, w.provider, w.model, w.branch_ref, w.branch_generation, + w.source, w.commit_format, w.forced, w.forced_reason, w.validation_failure, + w.offered_seqs, w.visible_original_seqs, w.hidden_seqs, w.selected_groups, + w.deferred_seqs, w.deferred_reasons` + +func scanIntentPlannerWindowSummaries(rows *sql.Rows) ([]intentPlannerWindowSummary, error) { + var out []intentPlannerWindowSummary + for rows.Next() { + win, err := scanIntentPlannerWindowSummary(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, win) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iter intent planner windows: %w", err) + } + return out, nil +} + +func scanIntentPlannerWindowSummariesWithEvent(rows *sql.Rows, eventSeq int64) ([]intentPlannerWindowSummary, error) { + var out []intentPlannerWindowSummary + for rows.Next() { + var offered, hidden, selected, deferred int + var groupOrd sql.NullInt64 + win, err := scanIntentPlannerWindowSummary(func(dest ...any) error { + return rows.Scan(append(dest, &offered, &hidden, &selected, &deferred, &groupOrd)...) + }) + if err != nil { + return nil, err + } + win.Event = &intentPlannerWindowEventParticipationSummary{ + Seq: eventSeq, + Offered: offered != 0, + Hidden: hidden != 0, + Selected: selected != 0, + Deferred: deferred != 0, + } + if groupOrd.Valid { + win.Event.GroupOrd = groupOrd.Int64 + } + out = append(out, win) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iter intent planner window events: %w", err) + } + return out, nil +} + +type scannerFunc func(dest ...any) error + +func scanIntentPlannerWindowSummary(scan scannerFunc) (intentPlannerWindowSummary, error) { + var win intentPlannerWindowSummary + var provider, model, source, commitFormat, forcedReason, validationFailure sql.NullString + var plannedTS float64 + var forced int + var offered, visible, hidden, groups, deferred, deferredReasons string + if err := scan( + &win.ID, &plannedTS, &provider, &model, &win.BranchRef, &win.BranchGeneration, + &source, &commitFormat, &forced, &forcedReason, &validationFailure, + &offered, &visible, &hidden, &groups, &deferred, &deferredReasons, + ); err != nil { + return intentPlannerWindowSummary{}, fmt.Errorf("scan intent planner window: %w", err) + } + win.PlannedTS = int64(plannedTS) + win.Time = time.Unix(int64(plannedTS), 0).Format(time.RFC3339) + win.Provider = provider.String + win.Model = model.String + win.Source = source.String + win.CommitFormat = commitFormat.String + win.Forced = forced != 0 + win.ForcedReason = forcedReason.String + win.ValidationFailure = validationFailure.String + if err := json.Unmarshal([]byte(emptyJSONArray(offered)), &win.OfferedSeqs); err != nil { + return intentPlannerWindowSummary{}, fmt.Errorf("unmarshal offered seqs: %w", err) + } + if err := json.Unmarshal([]byte(emptyJSONArray(visible)), &win.VisibleOriginalSeqs); err != nil { + return intentPlannerWindowSummary{}, fmt.Errorf("unmarshal visible original seqs: %w", err) + } + if err := json.Unmarshal([]byte(emptyJSONArray(hidden)), &win.HiddenSeqs); err != nil { + return intentPlannerWindowSummary{}, fmt.Errorf("unmarshal hidden seqs: %w", err) + } + if err := json.Unmarshal([]byte(emptyJSONArray(groups)), &win.SelectedGroups); err != nil { + return intentPlannerWindowSummary{}, fmt.Errorf("unmarshal selected groups: %w", err) + } + if err := json.Unmarshal([]byte(emptyJSONArray(deferred)), &win.DeferredSeqs); err != nil { + return intentPlannerWindowSummary{}, fmt.Errorf("unmarshal deferred seqs: %w", err) + } + if err := json.Unmarshal([]byte(emptyJSONArray(deferredReasons)), &win.DeferredReasons); err != nil { + return intentPlannerWindowSummary{}, fmt.Errorf("unmarshal deferred reasons: %w", err) + } + return win, nil +} + +func emptyJSONArray(raw string) string { + if raw == "" || raw == "null" { + return "[]" + } + return raw +} From 4733356d6a93a9ad90ecf1a22dc6d08adceee8de Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:37:32 +0300 Subject: [PATCH 32/42] Show last planner window in CLI - Include the latest planner window in strategy reports for CLI users. - Show offered, selected, deferred, hidden, and validation details when available so planner behavior is easier to inspect. --- internal/cli/intent_observability.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/cli/intent_observability.go b/internal/cli/intent_observability.go index d04a5b37..c7fa37cf 100644 --- a/internal/cli/intent_observability.go +++ b/internal/cli/intent_observability.go @@ -93,6 +93,7 @@ type intentStrategyReport struct { LastMessageQualityPath string `json:"last_message_quality_path,omitempty"` LastMessageQualityAction string `json:"last_message_quality_action,omitempty"` LastMessageQualityReason string `json:"last_message_quality_reason,omitempty"` + LastPlannerWindow *intentPlannerWindowSummary `json:"last_planner_window,omitempty"` // PlannerErrorRateRecent is the share of intent_planner_error rows in // the most recent IntentRecentDecisionWindow decisions. The denominator // is always IntentRecentDecisionWindow (default 100) regardless of how @@ -209,6 +210,21 @@ func renderIntentStrategyHuman(out io.Writer, r intentStrategyReport) { r.LastMessageQualityReason) } } + if r.LastPlannerWindow != nil { + win := r.LastPlannerWindow + fmt.Fprintf(out, "Last planner window: #%d provider=%s offered=%s selected_groups=%d deferred=%s\n", + win.ID, + valueOrUnset(win.Provider), + formatSeqs(win.OfferedSeqs), + len(win.SelectedGroups), + valueOrUnset(formatSeqs(win.DeferredSeqs))) + if len(win.HiddenSeqs) > 0 { + fmt.Fprintf(out, " Hidden/coalesced seqs: %s\n", formatSeqs(win.HiddenSeqs)) + } + if win.ValidationFailure != "" { + fmt.Fprintf(out, " Validation fallback: %s\n", win.ValidationFailure) + } + } if r.PlannerErrorRateRecent > 0 || r.SingletonCommitRateRecent > 0 { warn := "" if r.PlannerErrorRateRecentWarn { From 2553054bd3eb36a6ebb04c1424399a160082f759 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:37:45 +0300 Subject: [PATCH 33/42] Align CLI observability structs - Keep CLI observability structs consistently aligned - No functional behavior changes; this is readability cleanup --- internal/cli/events.go | 40 +++++++------- internal/cli/intent_observability.go | 79 +++++++++++++++------------- internal/cli/intent_windows.go | 2 +- 3 files changed, 63 insertions(+), 58 deletions(-) diff --git a/internal/cli/events.go b/internal/cli/events.go index 2fcec26a..a4ed2ed6 100644 --- a/internal/cli/events.go +++ b/internal/cli/events.go @@ -40,27 +40,27 @@ type eventsReport struct { } type eventEntry struct { - ID int64 `json:"id"` - Timestamp int64 `json:"timestamp"` - Time string `json:"time"` - Kind string `json:"kind"` - Path string `json:"path,omitempty"` - Reason string `json:"reason,omitempty"` - EventSeq int64 `json:"event_seq,omitempty"` - HeadSHA string `json:"head_sha,omitempty"` - CommitOID string `json:"commit_oid,omitempty"` - BranchRef string `json:"branch_ref,omitempty"` - BranchGeneration int64 `json:"branch_generation,omitempty"` - ActionTaken string `json:"action_taken,omitempty"` - UserMessage string `json:"user_message,omitempty"` - DecisionTS float64 `json:"decision_ts"` - GroupedSeqs []int64 `json:"grouped_seqs,omitempty"` - GroupSize int `json:"group_size,omitempty"` - IntentGroup bool `json:"intent_group,omitempty"` + ID int64 `json:"id"` + Timestamp int64 `json:"timestamp"` + Time string `json:"time"` + Kind string `json:"kind"` + Path string `json:"path,omitempty"` + Reason string `json:"reason,omitempty"` + EventSeq int64 `json:"event_seq,omitempty"` + HeadSHA string `json:"head_sha,omitempty"` + CommitOID string `json:"commit_oid,omitempty"` + BranchRef string `json:"branch_ref,omitempty"` + BranchGeneration int64 `json:"branch_generation,omitempty"` + ActionTaken string `json:"action_taken,omitempty"` + UserMessage string `json:"user_message,omitempty"` + DecisionTS float64 `json:"decision_ts"` + GroupedSeqs []int64 `json:"grouped_seqs,omitempty"` + GroupSize int `json:"group_size,omitempty"` + IntentGroup bool `json:"intent_group,omitempty"` PlannerWindow *intentPlannerWindowSummary `json:"planner_window,omitempty"` - Deferred bool `json:"deferred,omitempty"` - ForcedAging bool `json:"forced_aging,omitempty"` - PlannerError bool `json:"planner_error,omitempty"` + Deferred bool `json:"deferred,omitempty"` + ForcedAging bool `json:"forced_aging,omitempty"` + PlannerError bool `json:"planner_error,omitempty"` } func newEventsCmd() *cobra.Command { diff --git a/internal/cli/intent_observability.go b/internal/cli/intent_observability.go index c7fa37cf..e2fbeb61 100644 --- a/internal/cli/intent_observability.go +++ b/internal/cli/intent_observability.go @@ -56,43 +56,43 @@ func pathQuiescenceSnapshotFresh(ctx context.Context, conn *sql.DB) bool { } type intentStrategyReport struct { - Strategy string `json:"strategy"` - CommitFormat string `json:"commit_format"` - Active bool `json:"active"` - Window int `json:"window,omitempty"` - RecentCommits int `json:"recent_commits,omitempty"` - DeferLimit int `json:"defer_limit,omitempty"` - MinPending int `json:"min_pending,omitempty"` - SettleWindowSeconds int64 `json:"settle_window_seconds"` - MaxPendingAgeSeconds int64 `json:"max_pending_age_seconds,omitempty"` - IntentStageDiffCap int `json:"intent_stage_diff_cap,omitempty"` - VisiblePendingEvents int `json:"visible_pending_events,omitempty"` - OldestPendingEventSeq int64 `json:"oldest_pending_event_seq,omitempty"` - OldestPendingPath string `json:"oldest_pending_path,omitempty"` - OldestPendingAgeSeconds int64 `json:"oldest_pending_age_seconds,omitempty"` - NewestPendingEventSeq int64 `json:"newest_pending_event_seq,omitempty"` - NewestPendingAgeSeconds int64 `json:"newest_pending_age_seconds,omitempty"` - AgeTriggerTS int64 `json:"age_trigger_ts,omitempty"` - AgeTriggerInSeconds int64 `json:"age_trigger_in_seconds,omitempty"` - SettleTriggerTS int64 `json:"settle_trigger_ts,omitempty"` - SettleTriggerInSeconds int64 `json:"settle_trigger_in_seconds,omitempty"` - BatchWaitActive bool `json:"batch_wait_active,omitempty"` - BatchWaitReason string `json:"batch_wait_reason,omitempty"` - DeferredEvents int `json:"deferred_events,omitempty"` - MaxDeferCount int `json:"max_defer_count,omitempty"` - ForcedAgingReady int `json:"forced_aging_ready,omitempty"` - LastDeferredEventSeq int64 `json:"last_deferred_event_seq,omitempty"` - LastDeferredPath string `json:"last_deferred_path,omitempty"` - LastDeferredReason string `json:"last_deferred_reason,omitempty"` - LastPlannerErrorEventSeq int64 `json:"last_planner_error_event_seq,omitempty"` - LastPlannerErrorPath string `json:"last_planner_error_path,omitempty"` - LastPlannerError string `json:"last_planner_error,omitempty"` - MessageQualityRewriteCountRecent int `json:"message_quality_rewrite_count_recent,omitempty"` - MessageQualityFallbackCountRecent int `json:"message_quality_fallback_count_recent,omitempty"` - LastMessageQualityEventSeq int64 `json:"last_message_quality_event_seq,omitempty"` - LastMessageQualityPath string `json:"last_message_quality_path,omitempty"` - LastMessageQualityAction string `json:"last_message_quality_action,omitempty"` - LastMessageQualityReason string `json:"last_message_quality_reason,omitempty"` + Strategy string `json:"strategy"` + CommitFormat string `json:"commit_format"` + Active bool `json:"active"` + Window int `json:"window,omitempty"` + RecentCommits int `json:"recent_commits,omitempty"` + DeferLimit int `json:"defer_limit,omitempty"` + MinPending int `json:"min_pending,omitempty"` + SettleWindowSeconds int64 `json:"settle_window_seconds"` + MaxPendingAgeSeconds int64 `json:"max_pending_age_seconds,omitempty"` + IntentStageDiffCap int `json:"intent_stage_diff_cap,omitempty"` + VisiblePendingEvents int `json:"visible_pending_events,omitempty"` + OldestPendingEventSeq int64 `json:"oldest_pending_event_seq,omitempty"` + OldestPendingPath string `json:"oldest_pending_path,omitempty"` + OldestPendingAgeSeconds int64 `json:"oldest_pending_age_seconds,omitempty"` + NewestPendingEventSeq int64 `json:"newest_pending_event_seq,omitempty"` + NewestPendingAgeSeconds int64 `json:"newest_pending_age_seconds,omitempty"` + AgeTriggerTS int64 `json:"age_trigger_ts,omitempty"` + AgeTriggerInSeconds int64 `json:"age_trigger_in_seconds,omitempty"` + SettleTriggerTS int64 `json:"settle_trigger_ts,omitempty"` + SettleTriggerInSeconds int64 `json:"settle_trigger_in_seconds,omitempty"` + BatchWaitActive bool `json:"batch_wait_active,omitempty"` + BatchWaitReason string `json:"batch_wait_reason,omitempty"` + DeferredEvents int `json:"deferred_events,omitempty"` + MaxDeferCount int `json:"max_defer_count,omitempty"` + ForcedAgingReady int `json:"forced_aging_ready,omitempty"` + LastDeferredEventSeq int64 `json:"last_deferred_event_seq,omitempty"` + LastDeferredPath string `json:"last_deferred_path,omitempty"` + LastDeferredReason string `json:"last_deferred_reason,omitempty"` + LastPlannerErrorEventSeq int64 `json:"last_planner_error_event_seq,omitempty"` + LastPlannerErrorPath string `json:"last_planner_error_path,omitempty"` + LastPlannerError string `json:"last_planner_error,omitempty"` + MessageQualityRewriteCountRecent int `json:"message_quality_rewrite_count_recent,omitempty"` + MessageQualityFallbackCountRecent int `json:"message_quality_fallback_count_recent,omitempty"` + LastMessageQualityEventSeq int64 `json:"last_message_quality_event_seq,omitempty"` + LastMessageQualityPath string `json:"last_message_quality_path,omitempty"` + LastMessageQualityAction string `json:"last_message_quality_action,omitempty"` + LastMessageQualityReason string `json:"last_message_quality_reason,omitempty"` LastPlannerWindow *intentPlannerWindowSummary `json:"last_planner_window,omitempty"` // PlannerErrorRateRecent is the share of intent_planner_error rows in // the most recent IntentRecentDecisionWindow decisions. The denominator @@ -359,6 +359,11 @@ func loadIntentStrategyReport(ctx context.Context, conn *sql.DB) (intentStrategy if err := loadIntentMessageQualitySummary(ctx, conn, &report); err != nil { return report, err } + if lastWindow, err := loadLastIntentPlannerWindowSQL(ctx, conn); err != nil { + return report, err + } else { + report.LastPlannerWindow = lastWindow + } ok, err := sqliteTableExists(ctx, conn, "planner_state") if err != nil { return report, fmt.Errorf("planner_state table check: %w", err) diff --git a/internal/cli/intent_windows.go b/internal/cli/intent_windows.go index 2d2fc714..38a5b6ff 100644 --- a/internal/cli/intent_windows.go +++ b/internal/cli/intent_windows.go @@ -29,7 +29,7 @@ type intentPlannerWindowSummary struct { SelectedGroups []state.IntentPlannerWindowGroup `json:"selected_groups,omitempty"` DeferredSeqs []int64 `json:"deferred_seqs,omitempty"` DeferredReasons []state.IntentPlannerWindowDeferredReason `json:"deferred_reasons,omitempty"` - Event *intentPlannerWindowEventParticipationSummary `json:"event,omitempty"` + Event *intentPlannerWindowEventParticipationSummary `json:"event,omitempty"` } type intentPlannerWindowEventParticipationSummary struct { From 7e827737942260286be50ca3699d6408818718a7 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:38:39 +0300 Subject: [PATCH 34/42] Rename intent windows files - Update the state, CLI, and test files to use the planner windows name consistently. - Keep the rename aligned with the existing planner terminology exposed in recent commits. --- internal/cli/{intent_windows.go => intent_planner_windows.go} | 0 internal/state/{intent_windows.go => intent_planner_windows.go} | 0 .../{intent_windows_test.go => intent_planner_windows_test.go} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename internal/cli/{intent_windows.go => intent_planner_windows.go} (100%) rename internal/state/{intent_windows.go => intent_planner_windows.go} (100%) rename internal/state/{intent_windows_test.go => intent_planner_windows_test.go} (100%) diff --git a/internal/cli/intent_windows.go b/internal/cli/intent_planner_windows.go similarity index 100% rename from internal/cli/intent_windows.go rename to internal/cli/intent_planner_windows.go diff --git a/internal/state/intent_windows.go b/internal/state/intent_planner_windows.go similarity index 100% rename from internal/state/intent_windows.go rename to internal/state/intent_planner_windows.go diff --git a/internal/state/intent_windows_test.go b/internal/state/intent_planner_windows_test.go similarity index 100% rename from internal/state/intent_windows_test.go rename to internal/state/intent_planner_windows_test.go From 8ecec493563658e0b39e6059a30e031bb52bcd4f Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:39:07 +0300 Subject: [PATCH 35/42] Rename intent window files - Keep the intent planner filenames consistent with the new singular naming. - Rename the CLI, state, and test files together so the suite stays in sync. --- .../cli/{intent_planner_windows.go => intent_planner_window.go} | 0 .../state/{intent_planner_windows.go => intent_planner_window.go} | 0 ...tent_planner_windows_test.go => intent_planner_window_test.go} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename internal/cli/{intent_planner_windows.go => intent_planner_window.go} (100%) rename internal/state/{intent_planner_windows.go => intent_planner_window.go} (100%) rename internal/state/{intent_planner_windows_test.go => intent_planner_window_test.go} (100%) diff --git a/internal/cli/intent_planner_windows.go b/internal/cli/intent_planner_window.go similarity index 100% rename from internal/cli/intent_planner_windows.go rename to internal/cli/intent_planner_window.go diff --git a/internal/state/intent_planner_windows.go b/internal/state/intent_planner_window.go similarity index 100% rename from internal/state/intent_planner_windows.go rename to internal/state/intent_planner_window.go diff --git a/internal/state/intent_planner_windows_test.go b/internal/state/intent_planner_window_test.go similarity index 100% rename from internal/state/intent_planner_windows_test.go rename to internal/state/intent_planner_window_test.go From 724a2684c4acca4619d6eb238683e70b60f64808 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:40:29 +0300 Subject: [PATCH 36/42] Expose planner window in CLI output - Add JSON event coverage for planner-window details - Add status summary coverage for the last planner window --- internal/cli/events_test.go | 73 +++++++++++++++++++++++ internal/cli/intent_observability_test.go | 55 +++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/internal/cli/events_test.go b/internal/cli/events_test.go index 28e31341..59f399c9 100644 --- a/internal/cli/events_test.go +++ b/internal/cli/events_test.go @@ -265,6 +265,79 @@ func TestEventsJSONGroupedSeqsDoNotImplyIntentGroup(t *testing.T) { } } +func TestEventsJSONIncludesPlannerWindowForEvent(t *testing.T) { + roots := withIsolatedHome(t) + repo, dbPath, db := makeRepoStateDB(t) + registerRepo(t, roots, repo, dbPath, "codex") + ctx := context.Background() + + if _, err := state.AppendIntentPlannerWindow(ctx, db, state.IntentPlannerWindow{ + PlannedTS: 40, + Provider: sqlNullStr("openai-compat"), + Model: sqlNullStr("gpt-test"), + BranchRef: "refs/heads/main", + BranchGeneration: 1, + Source: sqlNullStr("openai-compat"), + CommitFormat: sqlNullStr("imperative"), + OfferedSeqs: []int64{10, 12}, + VisibleOriginalSeqs: []int64{10, 11, 12}, + HiddenSeqs: []int64{11}, + SelectedGroups: []state.IntentPlannerWindowGroup{{ + SelectedSeqs: []int64{10}, + OriginalSeqs: []int64{10, 11}, + Subject: "Update parser", + GroupingReason: "related parser edits", + }}, + DeferredSeqs: []int64{12}, + DeferredReasons: []state.IntentPlannerWindowDeferredReason{{ + Seq: 12, + Reason: "separate docs change", + }}, + Events: []state.IntentPlannerWindowEvent{ + {EventSeq: 10, Offered: true, Selected: true, GroupOrd: sql.NullInt64{Int64: 0, Valid: true}}, + {EventSeq: 11, Hidden: true, Selected: true, GroupOrd: sql.NullInt64{Int64: 0, Valid: true}}, + {EventSeq: 12, Offered: true, Deferred: true}, + }, + }); err != nil { + t.Fatalf("AppendIntentPlannerWindow: %v", err) + } + if _, err := state.AppendDecision(ctx, db, state.DecisionRecord{ + DecisionTS: 41, + Kind: state.DecisionKindCommitted, + Path: sqlNullStr("src/parser.go"), + EventSeq: sql.NullInt64{Int64: 11, Valid: true}, + CommitOID: sqlNullStr("commit123"), + ActionTaken: sqlNullStr("intent_group_committed"), + UserMessage: sqlNullStr("committed"), + }); err != nil { + t.Fatalf("AppendDecision: %v", err) + } + + var out bytes.Buffer + if err := runEvents(ctx, &out, repo, "", 0, 10, false, time.Millisecond, true); err != nil { + t.Fatalf("runEvents json: %v", err) + } + var rep eventsReport + if err := json.Unmarshal(out.Bytes(), &rep); err != nil { + t.Fatalf("decode events: %v\n%s", err, out.String()) + } + if len(rep.Events) != 1 { + t.Fatalf("events len = %d, want 1: %+v", len(rep.Events), rep.Events) + } + win := rep.Events[0].PlannerWindow + if win == nil || win.Provider != "openai-compat" || win.Model != "gpt-test" { + t.Fatalf("planner window = %+v", win) + } + if len(win.HiddenSeqs) != 1 || win.HiddenSeqs[0] != 11 || + win.Event == nil || !win.Event.Hidden || !win.Event.Selected || win.Event.GroupOrd != 0 { + t.Fatalf("planner event participation = %+v window=%+v", win.Event, win) + } + if len(win.SelectedGroups) != 1 || len(win.SelectedGroups[0].OriginalSeqs) != 2 || + win.SelectedGroups[0].OriginalSeqs[1] != 11 { + t.Fatalf("planner selected groups = %+v", win.SelectedGroups) + } +} + func preparePreDecisionLedgerDB(t *testing.T, db *state.DB, dbPath string) { t.Helper() if _, err := db.SQL().ExecContext(context.Background(), `DROP TABLE decision_records`); err != nil { diff --git a/internal/cli/intent_observability_test.go b/internal/cli/intent_observability_test.go index 94f71548..472f3c52 100644 --- a/internal/cli/intent_observability_test.go +++ b/internal/cli/intent_observability_test.go @@ -78,6 +78,61 @@ func TestStatus_MessageQualitySummary(t *testing.T) { } } +func TestStatus_LastPlannerWindowSummary(t *testing.T) { + roots := withIsolatedHome(t) + ctx := context.Background() + repo, dbPath, d := makeRepoStateDB(t) + registerRepo(t, roots, repo, dbPath, "claude-code") + + if _, err := state.AppendIntentPlannerWindow(ctx, d, state.IntentPlannerWindow{ + PlannedTS: 100, + Provider: sqlNullStr("openai-compat"), + Model: sqlNullStr("gpt-test"), + BranchRef: "refs/heads/main", + BranchGeneration: 1, + Source: sqlNullStr("deterministic"), + CommitFormat: sqlNullStr("imperative"), + ValidationFailure: sqlNullStr("planner validation failed"), + OfferedSeqs: []int64{4, 5}, + VisibleOriginalSeqs: []int64{4, 5, 6}, + HiddenSeqs: []int64{6}, + SelectedGroups: []state.IntentPlannerWindowGroup{{ + SelectedSeqs: []int64{4}, + OriginalSeqs: []int64{4, 6}, + Subject: "Update parser", + GroupingReason: "related parser edits", + }}, + DeferredSeqs: []int64{5}, + DeferredReasons: []state.IntentPlannerWindowDeferredReason{{ + Seq: 5, + Reason: "separate docs change", + }}, + }); err != nil { + t.Fatalf("AppendIntentPlannerWindow: %v", err) + } + + report := runStatusJSON(ctx, t, repo) + win := report.IntentStrategy.LastPlannerWindow + if win == nil || win.Provider != "openai-compat" || win.Model != "gpt-test" { + t.Fatalf("last planner window = %+v", win) + } + if len(win.OfferedSeqs) != 2 || win.OfferedSeqs[0] != 4 || + len(win.HiddenSeqs) != 1 || win.HiddenSeqs[0] != 6 || + win.ValidationFailure != "planner validation failed" { + t.Fatalf("last planner window seq/failure fields = %+v", win) + } + + var human bytes.Buffer + if err := runStatus(ctx, &human, repo, false); err != nil { + t.Fatalf("runStatus human: %v", err) + } + for _, want := range []string{"Last planner window", "offered=4,5", "Hidden/coalesced seqs: 6", "Validation fallback"} { + if !strings.Contains(human.String(), want) { + t.Fatalf("status human missing %q:\n%s", want, human.String()) + } + } +} + // TestStatus_PlannerErrorRateRecent_HalfWindow_FixedDenominator asserts // that with 50 decisions of which all 50 are planner errors, the rate is // 50/100 = 0.5 (NOT 50/50 = 1.0). Documents the fixed-denominator policy From 373b0a1c9b660975c3d4cc5760fe011534979c78 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:44:11 +0300 Subject: [PATCH 37/42] Verify planner window partitioning - Add integration coverage for planner-window partitioning - Assert independent intents stay separable in the daemon - Check the rapid-five path still reports one coalesced group --- test/integration/intent_atomicity_test.go | 254 ++++++++++++++++++++++ test/integration/intent_strategy_test.go | 7 + 2 files changed, 261 insertions(+) diff --git a/test/integration/intent_atomicity_test.go b/test/integration/intent_atomicity_test.go index bab73da8..bc4d564a 100644 --- a/test/integration/intent_atomicity_test.go +++ b/test/integration/intent_atomicity_test.go @@ -46,6 +46,19 @@ import ( "time" ) +type plannerWindowRow struct { + OfferedSeqs []int64 `json:"offered_seqs"` + VisibleOriginalSeqs []int64 `json:"visible_original_seqs"` + HiddenSeqs []int64 `json:"hidden_seqs"` + SelectedGroups []struct { + SelectedSeqs []int64 `json:"selected_seqs"` + OriginalSeqs []int64 `json:"original_seqs"` + Subject string `json:"subject"` + GroupingReason string `json:"grouping_reason"` + } `json:"selected_groups"` + DeferredSeqs []int64 `json:"deferred_seqs"` +} + // TestIntentAtomicity_FourFileBatchLandsAsOneGroupedCommit drives four // distinct creates through the real daemon under intent strategy. The // mock planner accepts every offered seq; the daemon must publish ONE @@ -355,3 +368,244 @@ func TestIntentAtomicity_DeferredMiddleSplitsCommit(t *testing.T) { t.Fatalf("planner hits=%d want 1 (single offered window for the three creates)", hits.Load()) } } + +func TestIntentAtomicity_PartitionWindowSplitsIndependentIntents(t *testing.T) { + if _, err := exec.LookPath("sqlite3"); err != nil { + t.Skip("sqlite3 binary required") + } + repo := tempRepo(t) + env := withIsolatedHome(t) + t.Cleanup(func() { stopSessionForce(t, env, repo) }) + + samePath := filepath.Join(repo, "same.go") + writeFile(t, samePath, "package main\n\nfunc alpha() string { return \"a0\" }\n\nfunc beta() string { return \"b0\" }\n") + gitCommitAll(t, repo, "seed same.go", "same.go") + + var hits atomic.Int32 + server, trustEnv := newOpenAITestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + if !strings.HasSuffix(r.URL.Path, "/chat/completions") { + http.Error(w, "wrong path", http.StatusNotFound) + return + } + req := decodeIntentChatRequest(t, r) + captures := offeredIntentCaptures(t, req) + if len(captures) != 5 { + http.Error(w, "expected five offered captures", http.StatusBadRequest) + return + } + var sameSeqs []int64 + byPath := map[string]int64{} + for _, capture := range captures { + if capture.Path == "same.go" { + sameSeqs = append(sameSeqs, capture.Seq) + continue + } + byPath[capture.Path] = capture.Seq + } + if len(sameSeqs) != 2 || byPath["feature-api.txt"] == 0 || + byPath["feature-ui.txt"] == 0 || byPath["unrelated-note.txt"] == 0 { + http.Error(w, "unexpected offered paths", http.StatusBadRequest) + return + } + commitGroups := []map[string]any{ + { + "selected_seqs": []int64{sameSeqs[0]}, + "subject": "Update alpha helper", + "body": "Keep the alpha function change atomic.", + "grouping_reason": "same-file alpha edit is independent", + }, + { + "selected_seqs": []int64{sameSeqs[1]}, + "subject": "Update beta helper", + "body": "Keep the beta function change atomic.", + "grouping_reason": "same-file beta edit is independent", + }, + { + "selected_seqs": []int64{byPath["feature-api.txt"], byPath["feature-ui.txt"]}, + "subject": "Add related feature files", + "body": "Group related API and UI notes together.", + "grouping_reason": "feature API and UI edits share one intent", + }, + { + "selected_seqs": []int64{byPath["unrelated-note.txt"]}, + "subject": "Add unrelated note", + "body": "Keep the unrelated note separately revertable.", + "grouping_reason": "unrelated note should not join feature work", + }, + } + selected := []int64{ + sameSeqs[0], + sameSeqs[1], + byPath["feature-api.txt"], + byPath["feature-ui.txt"], + byPath["unrelated-note.txt"], + } + plan := map[string]any{ + "selected_seqs": selected, + "deferred_seqs": []int64{}, + "subject": "Partition intent window", + "body": "Split one planner window into atomic groups.", + "grouping_reason": "partition close-together edits by intent", + "commit_groups": commitGroups, + "deferred_reasons": []map[string]any{}, + } + args, err := json.Marshal(plan) + if err != nil { + t.Fatalf("marshal intent plan: %v", err) + } + resp := map[string]any{ + "id": "chatcmpl-partition-window", + "object": "chat.completion", + "model": "gpt-5.4-mini", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": "", + "tool_calls": []map[string]any{{ + "id": "call_partition_window", + "type": "function", + "function": map[string]any{ + "name": "capture_intent_plan", + "arguments": string(args), + }, + }}, + }, + "finish_reason": "tool_calls", + }}, + } + body, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + extra := []string{ + "ACD_COMMIT_STRATEGY=intent", + "ACD_INTENT_WINDOW=10", + "ACD_INTENT_MIN_PENDING=5", + "ACD_INTENT_SETTLE_WINDOW=0", + "ACD_INTENT_MAX_PENDING_AGE=1h", + "ACD_AI_PROVIDER=openai-compat", + "ACD_AI_BASE_URL=" + server.URL, + "ACD_AI_API_KEY=test-key", + "ACD_AI_MODEL=gpt-5.4-mini", + trustEnv, + } + sessionID := "intent-partition-window" + startSession(t, ctx, env, repo, sessionID, "shell", extra...) + waitMode(t, repo, "running", 5*time.Second) + fullEnv := envWith(env, extra...) + dbPath := filepath.Join(repo, ".git", "acd", "state.db") + + startCount := commitCount(t, repo) + steps := []struct { + path string + body string + want string + }{ + {"same.go", "package main\n\nfunc alpha() string { return \"a1\" }\n\nfunc beta() string { return \"b0\" }\n", "1"}, + {"same.go", "package main\n\nfunc alpha() string { return \"a1\" }\n\nfunc beta() string { return \"b1\" }\n", "2"}, + {"feature-api.txt", "api half of a related feature\n", "3"}, + {"feature-ui.txt", "ui half of a related feature\n", "4"}, + {"unrelated-note.txt", "independent note\n", ""}, + } + for _, step := range steps { + writeFile(t, filepath.Join(repo, step.path), step.body) + wakeSession(t, ctx, fullEnv, repo, sessionID) + if step.want == "" { + continue + } + waitFor(t, "pending captures="+step.want, 10*time.Second, func() bool { + return sqliteScalar(t, dbPath, "SELECT COUNT(*) FROM capture_events WHERE state='pending'") == step.want + }) + } + + waitForEventState(t, dbPath, "same.go", "published", 25*time.Second) + for _, path := range []string{"feature-api.txt", "feature-ui.txt", "unrelated-note.txt"} { + waitForEventState(t, dbPath, path, "published", 25*time.Second) + } + waitFor(t, "two same.go captures published", 10*time.Second, func() bool { + return sqliteScalar(t, dbPath, "SELECT COUNT(*) FROM capture_events WHERE path='same.go' AND state='published'") == "2" + }) + + if hits.Load() != 1 { + t.Fatalf("planner hits=%d want 1 for one five-capture window", hits.Load()) + } + if got, want := commitCount(t, repo), startCount+4; got != want { + t.Fatalf("commit count=%d want %d (two same-file commits, one related group, one unrelated commit)", got, want) + } + + sameDistinct := sqliteScalar(t, dbPath, + "SELECT COUNT(DISTINCT commit_oid) FROM capture_events WHERE path='same.go' AND state='published'") + if sameDistinct != "2" { + t.Fatalf("same.go distinct commit_oids=%s want 2 (independent same-file edits should split)", sameDistinct) + } + featureAPI := sqliteScalar(t, dbPath, + "SELECT commit_oid FROM capture_events WHERE path='feature-api.txt' AND state='published'") + featureUI := sqliteScalar(t, dbPath, + "SELECT commit_oid FROM capture_events WHERE path='feature-ui.txt' AND state='published'") + unrelated := sqliteScalar(t, dbPath, + "SELECT commit_oid FROM capture_events WHERE path='unrelated-note.txt' AND state='published'") + if featureAPI == "" || featureAPI != featureUI { + t.Fatalf("feature commit_oids api=%q ui=%q want one related commit", featureAPI, featureUI) + } + if unrelated == "" || unrelated == featureAPI { + t.Fatalf("unrelated commit_oid=%q feature_oid=%q want separate commits", unrelated, featureAPI) + } + + win := loadLastPlannerWindowRow(t, dbPath) + if len(win.OfferedSeqs) != 5 || len(win.VisibleOriginalSeqs) != 5 || len(win.HiddenSeqs) != 0 { + t.Fatalf("planner window seqs = %+v, want five offered/visible and no hidden coalesce", win) + } + if len(win.SelectedGroups) != 4 || len(win.DeferredSeqs) != 0 { + t.Fatalf("planner selected groups = %+v deferred=%v, want four groups and no defers", win.SelectedGroups, win.DeferredSeqs) + } + + status := runAcd(t, ctx, fullEnv, "status", "--repo", repo, "--json") + if status.ExitCode != 0 { + t.Fatalf("acd status exit=%d\nstdout=%s\nstderr=%s", status.ExitCode, status.Stdout, status.Stderr) + } + if !strings.Contains(status.Stdout, `"last_planner_window"`) || + !strings.Contains(status.Stdout, `"selected_groups"`) { + t.Fatalf("status JSON missing planner-window summary:\n%s", status.Stdout) + } + events := runAcd(t, ctx, fullEnv, "events", "--repo", repo, "--json", "--limit", "20") + if events.ExitCode != 0 { + t.Fatalf("acd events exit=%d\nstdout=%s\nstderr=%s", events.ExitCode, events.Stdout, events.Stderr) + } + if !strings.Contains(events.Stdout, `"planner_window"`) || + !strings.Contains(events.Stdout, `"visible_original_seqs"`) { + t.Fatalf("events JSON missing planner-window summary:\n%s", events.Stdout) + } +} + +func loadLastPlannerWindowRow(t *testing.T, dbPath string) plannerWindowRow { + t.Helper() + raw := sqliteScalar(t, dbPath, ` +SELECT json_object( + 'offered_seqs', json(offered_seqs), + 'visible_original_seqs', json(visible_original_seqs), + 'hidden_seqs', json(hidden_seqs), + 'selected_groups', json(selected_groups), + 'deferred_seqs', json(deferred_seqs) +) +FROM intent_planner_windows +ORDER BY id DESC +LIMIT 1`) + if raw == "" { + t.Fatalf("missing intent_planner_windows row") + } + var row plannerWindowRow + if err := json.Unmarshal([]byte(raw), &row); err != nil { + t.Fatalf("decode planner window row: %v\n%s", err, raw) + } + return row +} diff --git a/test/integration/intent_strategy_test.go b/test/integration/intent_strategy_test.go index 8a544b38..a65cddf9 100644 --- a/test/integration/intent_strategy_test.go +++ b/test/integration/intent_strategy_test.go @@ -247,6 +247,13 @@ func TestIntentStrategy_RapidFiveCapturesOfferedTogether(t *testing.T) { if subj := headSubject(t, repo); subj != "Group rapid five" { t.Fatalf("subject=%q want grouped rapid-five subject", subj) } + win := loadLastPlannerWindowRow(t, dbPath) + if len(win.OfferedSeqs) != 5 || len(win.VisibleOriginalSeqs) != 5 || len(win.HiddenSeqs) != 0 { + t.Fatalf("rapid-five planner window = %+v, want five offered/visible seqs and no hidden coalesce", win) + } + if len(win.SelectedGroups) != 1 || len(win.SelectedGroups[0].OriginalSeqs) != 5 { + t.Fatalf("rapid-five selected groups = %+v, want one five-seq group", win.SelectedGroups) + } } type intentChatRequest struct { From c6ba17b98e40119e123ffc9187c433826560b5bb Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:47:01 +0300 Subject: [PATCH 38/42] Update intent atomicity test bodies - Align test fixtures with the new bullet-list commit body format - Keep intent atomicity expectations consistent with generated output - Ensure the partitioning test covers the final rendered message shape --- test/integration/intent_atomicity_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/integration/intent_atomicity_test.go b/test/integration/intent_atomicity_test.go index bc4d564a..cd3c5298 100644 --- a/test/integration/intent_atomicity_test.go +++ b/test/integration/intent_atomicity_test.go @@ -412,25 +412,25 @@ func TestIntentAtomicity_PartitionWindowSplitsIndependentIntents(t *testing.T) { { "selected_seqs": []int64{sameSeqs[0]}, "subject": "Update alpha helper", - "body": "Keep the alpha function change atomic.", + "body": "- Keep the alpha function change atomic.", "grouping_reason": "same-file alpha edit is independent", }, { "selected_seqs": []int64{sameSeqs[1]}, "subject": "Update beta helper", - "body": "Keep the beta function change atomic.", + "body": "- Keep the beta function change atomic.", "grouping_reason": "same-file beta edit is independent", }, { "selected_seqs": []int64{byPath["feature-api.txt"], byPath["feature-ui.txt"]}, "subject": "Add related feature files", - "body": "Group related API and UI notes together.", + "body": "- Group related API and UI notes together.", "grouping_reason": "feature API and UI edits share one intent", }, { "selected_seqs": []int64{byPath["unrelated-note.txt"]}, "subject": "Add unrelated note", - "body": "Keep the unrelated note separately revertable.", + "body": "- Keep the unrelated note separately revertable.", "grouping_reason": "unrelated note should not join feature work", }, } @@ -445,7 +445,7 @@ func TestIntentAtomicity_PartitionWindowSplitsIndependentIntents(t *testing.T) { "selected_seqs": selected, "deferred_seqs": []int64{}, "subject": "Partition intent window", - "body": "Split one planner window into atomic groups.", + "body": "- Split one planner window into atomic groups.", "grouping_reason": "partition close-together edits by intent", "commit_groups": commitGroups, "deferred_reasons": []map[string]any{}, From d6b79237495b496b289379ad210e7f9e7cdf76f7 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:49:44 +0300 Subject: [PATCH 39/42] Tune path quiescence test settings - Disable the settle window so the test uses immediate intent capture. - Enable path coalescing to make the two saves collapse into one path. - Keep the quiescence scenario deterministic under intent strategy. --- test/integration/intent_flush_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/integration/intent_flush_test.go b/test/integration/intent_flush_test.go index 80141a83..2323db9e 100644 --- a/test/integration/intent_flush_test.go +++ b/test/integration/intent_flush_test.go @@ -166,7 +166,9 @@ func TestPathQuiescence_TwoSavesWithinWindowBecomeOneCapture(t *testing.T) { "ACD_COMMIT_STRATEGY=intent", "ACD_INTENT_WINDOW=10", "ACD_INTENT_MIN_PENDING=1", + "ACD_INTENT_SETTLE_WINDOW=0", "ACD_INTENT_MAX_PENDING_AGE=5m", + "ACD_INTENT_PATH_COALESCE=1", "ACD_PATH_QUIESCENCE_SECONDS=2", ) From 5bb3bf35727ba556cfa51e53d880c3d44548b661 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 00:56:41 +0300 Subject: [PATCH 40/42] Document intent settle window and grouped planning - Give intent mode a burst settle delay so related edits can land together - Document grouped replay, planner-window diagnostics, and default env values --- CHANGELOG.md | 17 ++++++++++ CLAUDE.md | 18 ++++++----- README.md | 7 +++++ docs/ai-providers.md | 35 +++++++++++++++++++-- docs/capture-replay.md | 8 ++++- docs/intent-commit-flow.md | 64 +++++++++++++++++++++++++++----------- docs/user-workflows.md | 16 ++++++++-- 7 files changed, 134 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 333ab512..280e7b34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +### Added + +- Intent mode now has a short `ACD_INTENT_SETTLE_WINDOW` burst delay after the + pending-count gate, so rapid related edits can reach one planner-visible + window before AI grouping runs. +- `acd events --json`, `acd status --json`, and `acd diagnose --json` now expose + privacy-safe planner-window summaries showing offered, selected, deferred, + forced, hidden, and grouped seqs without requiring prompt-trace parsing. + +### Changed + +- Intent planning can partition one visible window into multiple ordered commit + groups, letting unrelated close-together changes and independent same-file + edits publish as separate atomic commits. +- Consecutive same-path captures are planner-visible by default. Set + `ACD_INTENT_PATH_COALESCE=1` to restore the legacy folding behavior. + ## v2026-06-21 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index e1376490..33e81f70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ make tidy # go mod tidy Pre-PR gate: ```bash -cleanenv() { env -u ACD_INTENT_MIN_PENDING -u ACD_INTENT_MAX_PENDING_AGE -u ACD_INTENT_WINDOW -u ACD_INTENT_RECENT_COMMITS -u ACD_INTENT_DEFER_LIMIT ACD_COMMIT_STRATEGY=event "$@"; } +cleanenv() { env -u ACD_INTENT_MIN_PENDING -u ACD_INTENT_MAX_PENDING_AGE -u ACD_INTENT_SETTLE_WINDOW -u ACD_INTENT_WINDOW -u ACD_INTENT_RECENT_COMMITS -u ACD_INTENT_DEFER_LIMIT ACD_COMMIT_STRATEGY=event "$@"; } cleanenv make lint cleanenv make test cleanenv go test ./test/integration/... -tags=integration -race -count=1 -timeout 5m @@ -49,7 +49,7 @@ ACD_VERSION=v2026-MM-DD sh scripts/install.sh | `cmd/acd/main.go` | CLI entrypoint | | `internal/cli` | Cobra commands: setup, hooks, status, diagnose, doctor, fix, commit-all, start cache | | `internal/daemon` | run loop, capture/replay, intent, branch tokens, bootstrap/shadow, fsnotify, refcount, live-index repair, trace | -| `internal/state` | SQLite v7: events/ops, decisions, planner_state, shadow/meta/clients/flush/safe-ignore/sensitive | +| `internal/state` | SQLite v11: events/ops, decisions, planner_state, planner windows, shadow/meta/clients/flush/safe-ignore/sensitive | | `internal/git` | bounded refs/tree/diff/blob/scratch-index/history/ignore helpers | | `internal/ai` | deterministic/openai-compat/subprocess providers, commit message prompts, intent planner | | `internal/adapter` | harness detection and markers; do not restore TODO stubs | @@ -104,7 +104,7 @@ Line 3+: bullet list for why/context - Repo DB: `/acd/state.db`; central registry/stats use XDG state/share. - Start cache: `/acd/start-cache-.json`, schema v2. `acd stop` removes matching/all caches. Atomic tmp+rename prevents corruption. -- `SchemaVersion = 10`: v5 `decision_records`; v6 `decision_records.event_seq`; v7 `planner_state`; v8-v9 rewrite-plan state; v10 `rewrite_plans.commit_format`. +- `SchemaVersion = 11`: v5 `decision_records`; v6 `decision_records.event_seq`; v7 `planner_state`; v8-v9 rewrite-plan state; v10 `rewrite_plans.commit_format`; v11 `intent_planner_windows`. - `shadow_paths` key `(branch_ref, branch_generation, path)`; read-heavy paths use `state.DB.ReadSQL()`. - Shadow bootstrap: 5000-row chunks; marker `shadow.bootstrapped::` only after all chunks commit; clean partial rows on failure. Empty active shadow with marker means delete marker and re-bootstrap. - Reseed prunes old generations via `ACD_SHADOW_RETENTION_GENERATIONS` default `1`. @@ -124,13 +124,15 @@ Line 3+: bullet list for why/context - Default `ACD_COMMIT_STRATEGY=event`: one capture per commit; must not call planner. - `ACD_COMMIT_STRATEGY=intent`: AI planner selects one capture or a non-empty subset; capture durability unchanged. -- Intent defaults: `ACD_INTENT_WINDOW=10`, `ACD_INTENT_MIN_PENDING=10`, `ACD_INTENT_MAX_PENDING_AGE=5m`, `ACD_INTENT_RECENT_COMMITS=5`, `ACD_INTENT_DEFER_LIMIT=1`, `ACD_INTENT_PATH_COALESCE=1`. +- Intent defaults: `ACD_INTENT_WINDOW=10`, `ACD_INTENT_MIN_PENDING=10`, `ACD_INTENT_SETTLE_WINDOW=10s`, `ACD_INTENT_MAX_PENDING_AGE=5m`, `ACD_INTENT_RECENT_COMMITS=5`, `ACD_INTENT_DEFER_LIMIT=1`; `ACD_INTENT_PATH_COALESCE` is off by default. - Planner must classify every offered seq as selected or deferred. Invalid/missing/unsafe output records `intent_planner_error` and falls back to deterministic one-item. +- Planner may return ordered `commit_groups` to partition one visible window into multiple atomic commits. Replay publishes valid groups sequentially. - `deferred_reasons[i].seq` must appear in `deferred_seqs`. Providers normalize spurious deferred reasons before validation and warn with dropped seqs. - Deferred stays pending in `planner_state`; at `defer_count >= ACD_INTENT_DEFER_LIMIT`, oldest overdue is forced one-item. -- Same-path coalesce folds consecutive captures touching one shared path into one planner entry. Boundaries: different path, multi-path, rename, delete, or `(branch_ref, branch_generation, base_head)` change. On success every original seq is marked published with the same `commit_oid`; `acd events --json grouped_seqs` shows the run. +- Same-path coalesce is legacy opt-in with truthy `ACD_INTENT_PATH_COALESCE`. By default, consecutive same-path captures remain separate planner-visible seqs. If coalescing is enabled, boundaries are different path, multi-path, rename, delete, or `(branch_ref, branch_generation, base_head)` change. On success every original seq is marked published with the same `commit_oid`; `acd events --json grouped_seqs` and planner-window `hidden_seqs` show the run. +- Planner windows persist in `intent_planner_windows`/`intent_planner_window_events`: offered seqs, visible original seqs, hidden/coalesced seqs, selected groups, deferred seqs/reasons, forced/fallback metadata, provider/model/source/format, and per-event flags. They are privacy-safe summaries, not raw diffs. - Planner rejects log: `/acd/planner-rejects.jsonl`, 5 MiB + `.1`, best effort. Fields include `ts`, `provider`, `offered_seqs`, `code`, `message`, raw response size, sha256, and parsed-plan summary. `raw_response` is redacted by default; verbatim only with truthy `ACD_INTENT_REJECTS_RAW`, which logs a startup warning. -- `acd status --json` and `acd diagnose --json` expose `intent_strategy`, including recent planner/singleton rates over fixed denominator 100. `diagnose` hints when `planner_error_rate_recent > 0.05`. +- `acd status --json` and `acd diagnose --json` expose `intent_strategy`, including settle fields, `last_planner_window`, and recent planner/singleton rates over fixed denominator 100. `diagnose` hints when `planner_error_rate_recent > 0.05`. - Per-pass scratch index `/acd/replay-*.index` is seeded from `cctx.BaseHead`; reads via `git.LsFilesIndex(...)`. - CAS targets literal `HEAD` via `git.UpdateRef`; named refs use `--no-deref`. - `DefaultReplayLimit = 64`; query `Limit+1`, trim, set `ReplaySummary.HasMore`. `DefaultReplayPerEventTimeout = 60s`; timeout/cancel marks event `failed` and stops batch. @@ -143,7 +145,7 @@ Line 3+: bullet list for why/context - `processBranchTokenChange` runs before capture and after flush drain; do not collapse. Post-flush recheck handles git surgery outside `wakeCh`. - Branch settle `100ms`; flush drain `DefaultFlushLimit = 256`. -- Daemon stamps `commit.strategy`, `intent.{window,min_pending,max_pending_age,recent_commits,defer_limit,diff_egress}`; CLI reads meta before env. +- Daemon stamps `commit.strategy`, `intent.{window,min_pending,settle_window,max_pending_age,recent_commits,defer_limit,diff_egress}`; CLI reads meta before env. - Startup sweeps `acknowledged` flush requests older than `OrphanFlushAckThreshold = 5m` to `failed`. - fsnotify dispatch must not block: runtime creates use `rewalkCh`/`rewalkWorker`; diagnostics `diagCh`; tail clamps `MaxDebounceTail = 500ms`; ENOSPC -> `errBudgetExceeded`. - Daemon log: `paths.Roots.RepoLogPath(repoHash)` (`~/.local/state/acd//daemon.log`) with rotation/compression. Hook log: `${XDG_STATE_HOME:-$HOME/.local/state}/acd/-hook.log`. @@ -227,5 +229,5 @@ LOG="${XDG_STATE_HOME:-$HOME/.local/state}/acd/-hook.log" | Shadow/rewind | `ACD_SHADOW_RETENTION_GENERATIONS=1`; `ACD_REWIND_GRACE_SECONDS=60` (`0` disables); `ACD_KEEP_DEAD_BRANCH_BARRIERS` disables auto-prune | | Capture | `ACD_SENSITIVE_GLOBS`; `ACD_SAFE_IGNORE`; `ACD_SAFE_IGNORE_EXTRA`; `ACD_MAX_PENDING_EVENTS`; `ACD_PATH_QUIESCENCE_SECONDS=0` (off; restart to apply; capture remains durable, planner offer waits for quiet path) | | AI | `ACD_AI_PROVIDER=deterministic|openai-compat|subprocess:`; `ACD_AI_BASE_URL`; `ACD_AI_API_KEY`; `ACD_AI_MODEL`; `ACD_AI_TIMEOUT=30s`; `ACD_AI_CA_FILE`; `ACD_AI_DIFF_EGRESS`; `ACD_COMMIT_FORMAT=imperative|conventional`; `ACD_INTENT_REJECTS_RAW` | -| Strategy | `ACD_COMMIT_STRATEGY=event|intent`; `ACD_INTENT_WINDOW=10`; `ACD_INTENT_MIN_PENDING=10`; `ACD_INTENT_MAX_PENDING_AGE=5m`; `ACD_INTENT_RECENT_COMMITS=5`; `ACD_INTENT_DEFER_LIMIT=1`; `ACD_INTENT_PATH_COALESCE=1`; `ACD_RECENT_COMMIT_AFFINITY_SECONDS=0`; planner cap `ai.IntentStageDiffCap=16000` | +| Strategy | `ACD_COMMIT_STRATEGY=event|intent`; `ACD_INTENT_WINDOW=10`; `ACD_INTENT_MIN_PENDING=10`; `ACD_INTENT_SETTLE_WINDOW=10s`; `ACD_INTENT_MAX_PENDING_AGE=5m`; `ACD_INTENT_RECENT_COMMITS=5`; `ACD_INTENT_DEFER_LIMIT=1`; `ACD_INTENT_PATH_COALESCE` off by default; `ACD_RECENT_COMMIT_AFFINITY_SECONDS=0`; planner cap `ai.IntentStageDiffCap=16000` | | Watcher/client | `ACD_FSNOTIFY_ENABLED`; `ACD_DISABLE_FSNOTIFY`; `ACD_MAX_INOTIFY_WATCHES`; `ACD_CLIENT_TTL_SECONDS` | diff --git a/README.md b/README.md index 430adb4d..635d4e50 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ export ACD_AI_DIFF_EGRESS=1 export ACD_COMMIT_STRATEGY=intent export ACD_INTENT_WINDOW=10 export ACD_INTENT_MIN_PENDING=4 +export ACD_INTENT_SETTLE_WINDOW=10s export ACD_INTENT_MAX_PENDING_AGE=5m export ACD_INTENT_DEFER_LIMIT=1 ~~~ @@ -137,6 +138,11 @@ export ACD_INTENT_DEFER_LIMIT=1 `ACD_AI_DIFF_EGRESS=1` lets the planner see redacted captured diffs. Leave it unset when the endpoint should receive metadata only. +Intent mode waits briefly after the pending-count gate before planning. This +settle window lets a burst of related edits reach one planner-visible window, +while `acd flush --logical` still drains the current visible batch from an +active harness session. + Message format: | Format | Example subject | Notes | @@ -317,6 +323,7 @@ git reset --hard | `ACD_AI_DIFF_EGRESS` | off | Truthy sends redacted captured diffs to providers that ask for diffs. | | `ACD_INTENT_WINDOW` | `10` | Max captures offered to one planner pass. | | `ACD_INTENT_MIN_PENDING` | `10` | Preferred count before planning. Lower it for sparse repos. | +| `ACD_INTENT_SETTLE_WINDOW` | `10s` | Burst settle delay after the count gate. `0` disables it. | | `ACD_INTENT_MAX_PENDING_AGE` | `5m` | Age escape hatch for sparse queues. | | `ACD_INTENT_DEFER_LIMIT` | `1` | Deferrals before ACD forces a one-capture window. | | `ACD_INTENT_RETRY_ON_INVALID` | `2` | Max correction retries after invalid planner output. | diff --git a/docs/ai-providers.md b/docs/ai-providers.md index eed33bc8..b975ca28 100644 --- a/docs/ai-providers.md +++ b/docs/ai-providers.md @@ -95,10 +95,11 @@ private repo unless the endpoint or plugin is trusted. | `ACD_COMMIT_FORMAT` | `imperative` | `imperative` keeps the current subject rules; `conventional` opts into scope-less Conventional Commit subjects. | | `ACD_INTENT_WINDOW` | `10` | Max captures offered to one planner pass. | | `ACD_INTENT_MIN_PENDING` | `10` | Preferred pending count before planning. | +| `ACD_INTENT_SETTLE_WINDOW` | `10s` | Burst settle delay after the count gate. `0` disables it. | | `ACD_INTENT_MAX_PENDING_AGE` | `5m` | Age trigger for sparse queues. | | `ACD_INTENT_RECENT_COMMITS` | `5` | Recent commits sent as compact context. | | `ACD_INTENT_DEFER_LIMIT` | `1` | Deferrals before forced one-capture planning. | -| `ACD_INTENT_PATH_COALESCE` | `1` | Folds consecutive same-path captures into one planner offer. | +| `ACD_INTENT_PATH_COALESCE` | off | Truthy restores legacy folding of consecutive same-path captures into one planner offer. | | `ACD_INTENT_RETRY_ON_INVALID` | `2` | Max correction retries after typed planner validation errors. `0` or false-like values disable retries. | | `ACD_INTENT_REJECTS_RAW` | off | Truthy stores raw rejected planner responses. Sensitive. | | `ACD_PATH_QUIESCENCE_SECONDS` | `0` | Waits for paths to go quiet before planner offer. Capture still persists. | @@ -168,7 +169,7 @@ Commit-message response: Intent planner requests set `request_type` to `intent_plan`, include `commit_format`, and include `planner_request.offered_captures`. The response -must classify every offered seq: +must classify every offered seq. A single-group response still works: ~~~json { @@ -185,12 +186,42 @@ must classify every offered seq: } ~~~ +For windows that contain several independent intents, return ordered +`commit_groups`: + +~~~json +{ + "version": 1, + "selected_seqs": [101, 103], + "deferred_seqs": [102], + "commit_groups": [ + { + "selected_seqs": [101], + "subject": "Add auth retry handling", + "body": "- Keep transient failures available for retry", + "grouping_reason": "Auth behavior change" + }, + { + "selected_seqs": [103], + "subject": "Document retry configuration", + "body": "", + "grouping_reason": "Documentation-only update" + } + ], + "deferred_reasons": [ + {"seq": 102, "reason": "Separate billing cleanup"} + ], + "error": "" +} +~~~ + Rules: | Rule | Why it exists | |---|---| | `selected_seqs` must be non-empty | Replay must make progress. | | Every offered seq must be selected or deferred | The planner cannot ignore work. | +| `commit_groups`, when present, must be ordered and non-overlapping | Replay publishes groups sequentially and must preserve chronology. | | `deferred_reasons` may mention only deferred seqs | Reasons stay aligned with the plan. | | `subject` must match `commit_format` | Wrong-format output gets rejected, corrected, or falls back deterministically. | | Non-empty `error` is a soft error | ACD keeps the plugin alive and falls back for that request. | diff --git a/docs/capture-replay.md b/docs/capture-replay.md index f7b8c507..0761aaf4 100644 --- a/docs/capture-replay.md +++ b/docs/capture-replay.md @@ -139,13 +139,14 @@ rewrite diffs are capped at 16000 bytes. | Strategy | Replay behavior | |---|---| | `event` | FIFO replay. One capture can become one commit. | -| `intent` | A bounded pending window goes to the planner. Selected seqs replay as one commit; deferred seqs stay pending. | +| `intent` | A bounded pending window goes to the planner. Selected groups publish sequentially; deferred seqs stay pending. | Intent waits for one of these: | Trigger | Meaning | |---|---| | `ACD_INTENT_MIN_PENDING` | Enough visible pending captures exist. | +| `ACD_INTENT_SETTLE_WINDOW` | After the count gate, wait briefly for bursty edits to stop arriving. | | `ACD_INTENT_MAX_PENDING_AGE` | Oldest visible capture reached the age escape hatch. | | `acd flush --logical --session-id ` | A registered harness session asks to drain the visible batch now. | | Forced aging | A repeatedly deferred capture reached `ACD_INTENT_DEFER_LIMIT`. | @@ -153,6 +154,11 @@ Intent waits for one of these: Plain `acd wake` nudges capture and replay. It does not bypass intent batch gates. +Planner-window records are stored separately from raw prompt traces. They show +which seqs were offered, selected, deferred, forced, or hidden by legacy +same-path coalescing. Prompt traces remain the opt-in source for exact provider +requests and may contain source text. + ## One-shot capture with `commit-all` `acd commit-all` is for a dirty worktree when the daemon was off. diff --git a/docs/intent-commit-flow.md b/docs/intent-commit-flow.md index 16232f7f..94cfd94c 100644 --- a/docs/intent-commit-flow.md +++ b/docs/intent-commit-flow.md @@ -18,6 +18,7 @@ export ACD_AI_API_KEY=... export ACD_AI_DIFF_EGRESS=1 export ACD_INTENT_WINDOW=5 export ACD_INTENT_MIN_PENDING=3 +export ACD_INTENT_SETTLE_WINDOW=10s export ACD_INTENT_MAX_PENDING_AGE=60s export ACD_INTENT_DEFER_LIMIT=1 ~~~ @@ -31,6 +32,7 @@ export ACD_AI_API_KEY=... export ACD_AI_DIFF_EGRESS=1 export ACD_INTENT_WINDOW=10 export ACD_INTENT_MIN_PENDING=8 +export ACD_INTENT_SETTLE_WINDOW=10s export ACD_INTENT_MAX_PENDING_AGE=5m export ACD_INTENT_DEFER_LIMIT=1 export ACD_INTENT_RETRY_ON_INVALID=1 @@ -52,27 +54,29 @@ flowchart TB A["Capture rows
state=pending"] --> B{"Batch ready?"} B -->|no| C["Wait for count,
age, or logical flush"] C --> B - B -->|yes| D["Build planner window"] - D --> E["AI planner"] - E --> F{"Plan valid?"} - F -->|no| G["Deterministic
one-capture fallback"] - F -->|yes| H{"Message OK?"} - H -->|needs rewrite| I["Locked message rewrite"] - I --> J{"Rewrite valid?"} - J -->|no| G - H -->|yes| K["Replay selected seqs"] - J -->|yes| K - G --> K - K --> L[("decision ledger")] + B -->|yes| D{"Burst settled?"} + D -->|no| C + D -->|yes| E["Build planner window"] + E --> F["AI planner"] + F --> G{"Plan valid?"} + G -->|no| H["Deterministic
one-capture fallback"] + G -->|yes| I{"Message OK?"} + I -->|needs rewrite| J["Locked message rewrite"] + J --> K{"Rewrite valid?"} + K -->|no| H + I -->|yes| L["Replay selected group(s)"] + K -->|yes| L + H --> L + L --> M[("decision ledger")] classDef queue fill:#243447,stroke:#7aa2f7,color:#e6edf3 classDef decision fill:#3d2f1f,stroke:#f6c177,color:#fff4d6 classDef provider fill:#203a31,stroke:#9ece6a,color:#eaffdf classDef fallback fill:#402b2b,stroke:#f7768e,color:#ffe8ee - class A,L queue - class B,F,H,J decision - class E,I provider - class G fallback + class A,M queue + class B,D,G,I,K decision + class F,J provider + class H fallback ~~~ ## Planner input @@ -85,10 +89,22 @@ flowchart TB | `forced_aging` | True when a repeatedly deferred capture is forced into a one-capture window. | | `path_recent_commits` | Optional hint that an offered path recently changed at HEAD. It is not an amend feature. | +By default, consecutive captures that touch the same path remain separate +planner-visible seqs. This lets the planner split independent edits inside one +file when replay can apply them safely. `ACD_INTENT_PATH_COALESCE=1` restores +the legacy behavior that folds consecutive same-path captures into one planner +offer; `hidden_seqs` then records the original seqs covered by that folded +offer. + The planner must put every offered seq in either `selected_seqs` or `deferred_seqs`. It may select one seq or a larger related subset. Deferred seqs need reasons. +For a mixed window, the planner may return `commit_groups`. Groups publish in +the returned order, each with its own selected seqs, message, and grouping +reason. Top-level `selected_seqs` remains the union of all group selections for +legacy compatibility. + ## Message rules `ACD_COMMIT_FORMAT=imperative` is the default and keeps the existing rules. @@ -139,6 +155,11 @@ deterministic one-capture message in the selected format. Deferral is normal. It means the planner decided a capture did not belong in the current commit. +After `ACD_INTENT_MIN_PENDING` is reached, ACD waits for +`ACD_INTENT_SETTLE_WINDOW` with no newer visible capture before planning. A full +`ACD_INTENT_WINDOW`, `ACD_INTENT_MAX_PENDING_AGE`, forced aging, or +`acd flush --logical` bypasses that settle wait. + When a capture reaches `ACD_INTENT_DEFER_LIMIT`, ACD forces it through a one-capture planning window unless an earlier related-path capture must land first. If the provider fails there, deterministic fallback publishes the @@ -155,5 +176,12 @@ capture safely. | Where are rejected planner responses? | `/acd/planner-rejects.jsonl` | `intent_strategy` reports `commit_format`, window settings, batch wait state, -deferred counts, forced-aging readiness, planner error rate, singleton commit -rate, and message-quality rewrite or fallback counts. +settle countdowns, deferred counts, forced-aging readiness, planner error rate, +singleton commit rate, message-quality rewrite or fallback counts, and the most +recent privacy-safe `last_planner_window` summary. + +`acd events --json` adds `planner_window` to decisions with a known planner +window. The summary shows `offered_seqs`, `visible_original_seqs`, +`hidden_seqs`, `selected_groups`, `deferred_seqs`, forced/fallback metadata, +and per-event participation flags. It never stores raw diffs; use prompt traces +only when the exact provider payload is needed. diff --git a/docs/user-workflows.md b/docs/user-workflows.md index 64d9afa2..15016885 100644 --- a/docs/user-workflows.md +++ b/docs/user-workflows.md @@ -168,6 +168,7 @@ Intent mode may wait before asking the planner. | Gate | Meaning | |---|---| | `ACD_INTENT_MIN_PENDING` | Wait for this many visible pending captures. | +| `ACD_INTENT_SETTLE_WINDOW` | After the count gate, wait for a quiet burst boundary before planning. | | `ACD_INTENT_MAX_PENDING_AGE` | Publish when the oldest visible capture reaches this age. | | `ACD_INTENT_WINDOW` | Offer at most this many captures to the planner. | | `ACD_INTENT_DEFER_LIMIT` | Force a capture after this many deferrals. | @@ -189,6 +190,15 @@ acd flush --repo . --session-id "$ACD_SESSION_ID" --logical Plain `acd wake` does not bypass intent batch gates. +To check whether work was captured, planner-visible, and committed as intended: + +| Question | Command | +|---|---| +| Which captures are still waiting? | `acd status --json` | +| What did the last planner window contain? | `acd status --json` and inspect `intent_strategy.last_planner_window` | +| Was one seq offered, selected, deferred, or hidden? | `acd events --json --since ` and inspect `planner_window` | +| Why is the planner waiting? | `acd diagnose --json` and inspect `batch_wait_reason` plus settle fields | + ## Inspect an AI prompt Prompt tracing is opt-in because traces may contain source text and provider @@ -216,8 +226,10 @@ acd prompt --last acd prompt --seq 42 ~~~ -In intent mode, `--seq` can match any planner window where that seq was -offered. Remove `/acd/prompt-trace/` after debugging. +In intent mode, `--seq` can match any prompt-traced planner window where that +seq was offered. For privacy-safe metadata without raw prompts or diffs, use +`acd events --json` or `acd status --json`. Remove +`/acd/prompt-trace/` after debugging. ## Branch surgery From d986d6db3439d1969bd5e1b8218d31bf5e982708 Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 01:03:26 +0300 Subject: [PATCH 41/42] Reject interleaved commit groups - Require commit groups to stay in non-overlapping seq ranges - Keep replay deterministic by falling back when planner output is invalid - Update docs and tests to match the tightened planner contract --- docs/ai-providers.md | 8 +++ internal/ai/intent_planner.go | 30 +++++++---- internal/ai/intent_planner_test.go | 28 ++++++++++ internal/daemon/replay_test.go | 62 +++++++++++++++++++++++ test/integration/intent_atomicity_test.go | 10 ++-- 5 files changed, 122 insertions(+), 16 deletions(-) diff --git a/docs/ai-providers.md b/docs/ai-providers.md index b975ca28..609bfb26 100644 --- a/docs/ai-providers.md +++ b/docs/ai-providers.md @@ -194,6 +194,9 @@ For windows that contain several independent intents, return ordered "version": 1, "selected_seqs": [101, 103], "deferred_seqs": [102], + "subject": "Add auth retry handling", + "body": "- Keep transient failures available for retry", + "grouping_reason": "Auth retry handling and retry docs are separate commits", "commit_groups": [ { "selected_seqs": [101], @@ -215,6 +218,11 @@ For windows that contain several independent intents, return ordered } ~~~ +The top-level `selected_seqs`, `subject`, `body`, and `grouping_reason` remain +required for legacy compatibility. When `commit_groups` is present, +`selected_seqs` must be the union of all group selections; the top-level +message can mirror the first group or summarize the selected window. + Rules: | Rule | Why it exists | diff --git a/internal/ai/intent_planner.go b/internal/ai/intent_planner.go index b1c50192..3fab0395 100644 --- a/internal/ai/intent_planner.go +++ b/internal/ai/intent_planner.go @@ -418,7 +418,7 @@ func ValidateIntentPlan(req IntentPlanRequest, plan IntentPlan) error { } selected := make(map[int64]struct{}, len(groups)) var selectedSeqs []int64 - lastGroupFirstSeq := int64(0) + lastGroupMaxSeq := int64(0) for groupIndex, group := range groups { if len(group.SelectedSeqs) == 0 { return &IntentPlanValidationError{ @@ -438,20 +438,15 @@ func ValidateIntentPlan(req IntentPlanRequest, plan IntentPlan) error { Message: fmt.Sprintf("intent planner: commit_groups[%d].grouping_reason must be non-empty", groupIndex), } } - groupFirstSeq := int64(0) - for _, seq := range group.SelectedSeqs { - if groupFirstSeq == 0 || seq < groupFirstSeq { - groupFirstSeq = seq - } - } - if lastGroupFirstSeq != 0 && groupFirstSeq < lastGroupFirstSeq { + groupMinSeq, groupMaxSeq := groupSeqRange(group.SelectedSeqs) + if lastGroupMaxSeq != 0 && groupMinSeq < lastGroupMaxSeq { return &IntentPlanValidationError{ Code: IntentPlanValidationShape, - Seq: groupFirstSeq, - Message: "intent planner: commit_groups must be ordered by selected seq", + Seq: groupMinSeq, + Message: "intent planner: commit_groups must be ordered by non-overlapping selected seq ranges", } } - lastGroupFirstSeq = groupFirstSeq + lastGroupMaxSeq = groupMaxSeq for _, seq := range group.SelectedSeqs { if _, ok := offered[seq]; !ok { return &IntentPlanValidationError{ @@ -567,6 +562,19 @@ func ValidateIntentPlan(req IntentPlanRequest, plan IntentPlan) error { return nil } +func groupSeqRange(seqs []int64) (int64, int64) { + minSeq, maxSeq := int64(0), int64(0) + for _, seq := range seqs { + if minSeq == 0 || seq < minSeq { + minSeq = seq + } + if maxSeq == 0 || seq > maxSeq { + maxSeq = seq + } + } + return minSeq, maxSeq +} + // IntentPlanCommitGroups returns the ordered publish groups for a plan. // Legacy single-group plans are projected into one group. func IntentPlanCommitGroups(plan IntentPlan) ([]IntentCommitGroup, error) { diff --git a/internal/ai/intent_planner_test.go b/internal/ai/intent_planner_test.go index dbee6afd..7ba787bd 100644 --- a/internal/ai/intent_planner_test.go +++ b/internal/ai/intent_planner_test.go @@ -356,6 +356,34 @@ func TestValidateIntentPlanRejectsUnorderedCommitGroups(t *testing.T) { } } +func TestValidateIntentPlanRejectsInterleavedCommitGroups(t *testing.T) { + req := sampleIntentPlanRequest(t) + req.OfferedCaptures = append(req.OfferedCaptures, OfferedCapture{ + Seq: 103, + Path: "checkout/service.go", + Op: "modify", + Timestamp: req.OfferedCaptures[1].Timestamp, + Fidelity: "exact", + }) + plan := IntentPlan{ + SelectedSeqs: []int64{101, 102, 103}, + CommitGroups: []IntentCommitGroup{ + {SelectedSeqs: []int64{101, 103}, Subject: "Update checkout service", GroupingReason: "interleaved range"}, + {SelectedSeqs: []int64{102}, Subject: "Update checkout docs", GroupingReason: "middle capture"}, + }, + DeferredSeqs: []int64{}, + DeferredReasons: []DeferredReason{}, + } + err := ValidateIntentPlan(req, plan) + if err == nil { + t.Fatalf("expected interleaved commit_groups rejection") + } + var typed *IntentPlanValidationError + if !errors.As(err, &typed) || typed.Code != IntentPlanValidationShape { + t.Fatalf("error=%T %v want shape validation error", err, err) + } +} + func TestValidateIntentPlanReturnsTypedErrorForSelectedDeferredOverlap(t *testing.T) { req := sampleIntentPlanRequest(t) plan := IntentPlan{ diff --git a/internal/daemon/replay_test.go b/internal/daemon/replay_test.go index 78e30a27..adda2f0a 100644 --- a/internal/daemon/replay_test.go +++ b/internal/daemon/replay_test.go @@ -3227,6 +3227,68 @@ func TestReplay_IntentStrategyPublishesPartitionGroupsSequentially(t *testing.T) assertReplayDecision(t, ctx, f.db, pending[1].Seq, state.DecisionKindCommitted, "intent_group: second capture is independent") } +func TestReplay_IntentStrategyRejectsInterleavedSamePathPartition(t *testing.T) { + f := newCaptureFixture(t) + ctx := context.Background() + + seedTrackedFileCommit(t, ctx, f, "chain.txt", "v0\n") + if _, err := BootstrapShadow(ctx, f.dir, f.db, f.cctx); err != nil { + t.Fatalf("BootstrapShadow: %v", err) + } + seq1 := captureSamePathEdit(t, ctx, f, "chain.txt", "v1\n") + seq2 := captureSamePathEdit(t, ctx, f, "chain.txt", "v2\n") + seq3 := captureSamePathEdit(t, ctx, f, "chain.txt", "v3\n") + + planner := &recordingIntentPlanner{ + plan: ai.IntentPlan{ + SelectedSeqs: []int64{seq1, seq2, seq3}, + CommitGroups: []ai.IntentCommitGroup{ + {SelectedSeqs: []int64{seq1, seq3}, Subject: "Update chain endpoints", GroupingReason: "invalid interleaved same-path group"}, + {SelectedSeqs: []int64{seq2}, Subject: "Update chain middle", GroupingReason: "middle same-path capture"}, + }, + DeferredSeqs: []int64{}, + DeferredReasons: []ai.DeferredReason{}, + }, + } + + sum, err := Replay(ctx, f.dir, f.db, f.cctx, ReplayOpts{ + GitDir: f.gitDir, + CommitStrategy: ai.CommitStrategyIntent, + IntentPlanner: planner, + IntentWindow: 10, + IntentMinPending: 3, + }) + if err != nil { + t.Fatalf("Replay: %v", err) + } + if sum.Published != 1 || sum.Conflicts != 0 || sum.Failed != 0 { + t.Fatalf("summary=%+v want deterministic fallback publish without blocking", sum) + } + if state1 := captureEventState(t, ctx, f.db, seq1); state1 != state.EventStatePublished { + t.Fatalf("seq1 state=%q want published fallback", state1) + } + for _, seq := range []int64{seq2, seq3} { + if got := captureEventState(t, ctx, f.db, seq); got != state.EventStatePending { + t.Fatalf("seq=%d state=%q want pending after fallback", seq, got) + } + } + decisions, err := state.DecisionsForEvent(ctx, f.db, seq1, 10) + if err != nil { + t.Fatalf("DecisionsForEvent: %v", err) + } + foundPlannerError := false + for _, decision := range decisions { + if decision.Kind == state.DecisionKindIntentPlannerError && + decision.Reason.Valid && + decision.Reason.String == "intent planner: commit_groups must be ordered by non-overlapping selected seq ranges" { + foundPlannerError = true + } + } + if !foundPlannerError { + t.Fatalf("missing planner error decision for seq %d: %+v", seq1, decisions) + } +} + func TestReplay_IntentStrategyRecordsDeferralsAndForcesAgingWindow(t *testing.T) { f := newCaptureFixture(t) ctx := context.Background() diff --git a/test/integration/intent_atomicity_test.go b/test/integration/intent_atomicity_test.go index cd3c5298..b131efbe 100644 --- a/test/integration/intent_atomicity_test.go +++ b/test/integration/intent_atomicity_test.go @@ -7,12 +7,12 @@ package integration_test // the b1 outcome (intent grouped publishes are atomic) that landed earlier // in this branch. // -// The exhaustive same-path 4-edit coalesce proof lives at the daemon +// The exhaustive same-path 4-edit visibility proof lives at the daemon // package level in internal/daemon/replay_test.go (see -// TestReplay_IntentPathCoalesce_FoldsFourEditsIntoOneOffer): four sequential -// captures on burst.txt fold into a single planner offer, every covered seq -// shares the resulting commit_oid, and decision_records carries one row per -// original seq joined by commit_oid. +// TestReplay_IntentSamePathCapturesRemainPlannerVisible): four sequential +// captures on burst.txt stay planner-visible, the planner may select them +// into one commit, and decision_records carries one row per original seq +// joined by commit_oid. // // The integration suite cannot drive four sequential same-path captures // deterministically: `acd pause` halts capture as well as replay, so a From e9860d71976851640c4800d878d1350ce7808e5a Mon Sep 17 00:00:00 2001 From: KristjanPikhof Date: Tue, 23 Jun 2026 01:24:13 +0300 Subject: [PATCH 42/42] Document commit_groups planner guidance - Document the new planner contract for multi-intent windows. - Explain that ordered `commit_groups` preserve atomic replay. - Clarify custom planners should follow the same selection behavior. --- CHANGELOG.md | 2 ++ README.md | 4 ++++ docs/ai-providers.md | 4 ++++ docs/intent-commit-flow.md | 5 +++++ 4 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 280e7b34..34f92d68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - Intent planning can partition one visible window into multiple ordered commit groups, letting unrelated close-together changes and independent same-file edits publish as separate atomic commits. +- The intent planner prompt now explicitly asks for ordered `commit_groups` + when one visible window contains multiple independent commit intents. - Consecutive same-path captures are planner-visible by default. Set `ACD_INTENT_PATH_COALESCE=1` to restore the legacy folding behavior. diff --git a/README.md b/README.md index 635d4e50..8457afba 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,10 @@ settle window lets a burst of related edits reach one planner-visible window, while `acd flush --logical` still drains the current visible batch from an active harness session. +When one visible window contains separate intents, the planner prompt asks for +ordered `commit_groups` so replay can publish atomic commits instead of forcing +the whole window into one commit. + Message format: | Format | Example subject | Notes | diff --git a/docs/ai-providers.md b/docs/ai-providers.md index 609bfb26..f8ff1278 100644 --- a/docs/ai-providers.md +++ b/docs/ai-providers.md @@ -223,6 +223,10 @@ required for legacy compatibility. When `commit_groups` is present, `selected_seqs` must be the union of all group selections; the top-level message can mirror the first group or summarize the selected window. +ACD's built-in prompt tells the planner to use `commit_groups` for independent +intents inside one visible window. Custom subprocess planners should follow the +same contract instead of returning one broad selected group for unrelated work. + Rules: | Rule | Why it exists | diff --git a/docs/intent-commit-flow.md b/docs/intent-commit-flow.md index 94cfd94c..8c839652 100644 --- a/docs/intent-commit-flow.md +++ b/docs/intent-commit-flow.md @@ -105,6 +105,11 @@ the returned order, each with its own selected seqs, message, and grouping reason. Top-level `selected_seqs` remains the union of all group selections for legacy compatibility. +The built-in planner prompt asks for `commit_groups` when one visible window +contains multiple independent commit intents. It also requires chronological +groups, so replay can apply each group in order without selecting a later +same-path capture before an earlier one. + ## Message rules `ACD_COMMIT_FORMAT=imperative` is the default and keeps the existing rules.