SelfImprovementService convergence auto-close (rc#1003 / rc#947 OQ3)¶
Tracking: rig-conductor#1003 · parent rc#947 (open question 3)
Problem¶
Watchers in the rc#947 SelfImprovementService file gap-analysis issues when their signature crosses threshold. Until today, those issues had no auto-close path. When the underlying pattern resolved, the auto-filed issue stayed open as stale work until someone manually triaged it.
Observed live on 2026-05-17: 4 stuck-pattern auto-files (#938-#941) referenced dashe-website issues that resolved on 2026-05-16 morning, but the rig-conductor issues stayed open ~24 h until the orchestrator hand-closed them. With 8 watchers running 15-min ticks, the backlog accumulates faster than manual cleanup can keep up.
Solution¶
Pure decision policy in ConductorE.Core/SelfImprovement/ConvergenceDecider.cs:
public static ConvergenceAction Decide(
int windowOccurrenceCount,
int consecutiveCleanTicks,
bool hasOpenIssue,
int convergenceThreshold)
The orchestrator passes fresh.Count (this tick's watcher evaluation) as windowOccurrenceCount — NOT the persisted historical-window count. Persisted occurrences stay in window for the full DefaultThreshold.Window (often days), so using the historical count would prevent the clean counter from ever incrementing. The convergence intent is "the watcher's current evaluation produces no new evidence" → fresh.Count == 0.
| Input state | Action |
|---|---|
fresh.Count > 0 |
ResetCleanCounter (counter → 0) |
fresh.Count == 0, no open issue |
None (no-op) |
fresh.Count == 0, open issue, counter+1 < threshold |
IncrementCleanCounter |
fresh.Count == 0, open issue, counter+1 ≥ threshold |
CloseAndReset (comment + close issue, counter → 0 — but only if close confirmed; see below) |
The orchestrator (SelfImprovementService.ProcessWatcherAsync) calls Decide at the end of each watcher tick after the existing threshold-crossing logic; delegates the comment + PATCH side-effects to IAppTokenGitHubIssueWriter when the action is CloseAndReset (rc#1288 PR D).
Close-confirmation contract¶
CloseConvergedIssueAsync returns bool:
true— close confirmed (writer's PATCH succeeded, OR writer's pre-check found the issue already closed/deleted on GitHub). Orchestrator clearsOpenIssueand resets counter normally.false— transient failure (writer reports no App token, HTTP throw, non-2xx PATCH). Orchestrator retainsOpenIssueAND rolls back the counter increment so the next tick retries the close. MirrorsResolveOpenIssueAsync's shape.
Pre-check: lives inside the shared HttpGitHubIssueWriter adapter. Before posting the convergence comment, the writer does GET /repos/{r}/issues/{n}. If state=closed (operator hand-closed) or 404 (deleted), returns true without posting a stale "auto-closed by..." comment.
Default threshold = 3 ticks¶
3 × 15 min = 45 min of all-clean. Single transient clean tick (e.g. brief GitHub API outage) won't false-close. Three in a row is "the underlying pattern is genuinely gone."
Per-signature override could be added later via a new field on ISelfImprovementWatcher; deferred until a watcher justifies it.
Schema change¶
SelfImprovementSignatureState gains one field:
Defaults to 0 — additive, no migration needed. Existing rows persist as-is and start incrementing on next clean tick.
What the auto-close comment says¶
Auto-closed by SelfImprovementService (rc#1003 convergence) — signature
`<name>` has been clean for <N> consecutive scan ticks (last occurrence
was at <ISO timestamp>). If the underlying pattern returns, a fresh
gap-analysis issue will be filed automatically.
Filed via the conductor's IGitHubAppTokenProvider — same App identity that filed the issue originally. PATCH includes state_reason: completed.
Edge cases handled¶
- Operator manually closes the issue: the existing
ResolveOpenIssueAsyncpath already detects this and clearsOpenIssue. Convergence path seeshasOpenIssue=falseand falls through toNone. - Watcher evaluation throws: orchestrator's try/catch skips the watcher entirely; counter is NOT incremented because no Decide call happens. Treats "unknown" as "not yet clean."
- Close PATCH fails (e.g. transient HTTP): logs warning; orchestrator continues.
OpenIssuepointer is cleared regardless, so the next threshold-crossing files a fresh issue. The old stale issue stays open and can be hand-closed; acceptable cost. - Convergence + re-trip in same tick: impossible —
Decideenforces mutual exclusion (any occurrence resets the counter before convergence can trigger).
Pair with rc#1001 (stale-projection watcher)¶
Convergence assumes the watcher's signal accurately reflects reality. If the watcher's input is wrong (e.g. stale IssueStatus projection per rc#1000), it'll trip on phantom evidence and convergence won't fire. The rc#1001 stale-projection watcher closes that observability hole; landing both lets the system fully self-clean.
Out of scope¶
- Auto-reopen on re-tripping (simpler: file fresh issue, operator can grep history).
- Per-watcher convergence thresholds (default applies to all; revisit if needed).
- Auto-close of legacy stuck-pattern issues filed by
StuckWatcherService(different code path; the convergence here only applies to rc#947 watchers).
Files¶
- Pure decider:
src/ConductorE.Core/SelfImprovement/ConvergenceDecider.cs - Tests:
tests/ConductorE.Core.Tests/SelfImprovement/ConvergenceDeciderTests.cs(23 cases) - Orchestrator wiring:
src/ConductorE.Api/Services/SelfImprovement/SelfImprovementService.cs(CloseConvergedIssueAsync+ Decide call inProcessWatcherAsync) - Shared writer port:
src/ConductorE.Core/Ports/IGitHubIssueWriter.cs+IAppTokenGitHubIssueWriter.cs(rc#1288 PR D) - Adapter (App-token variant for SelfImprovement):
src/ConductorE.Api/Adapters/AppTokenHttpGitHubIssueWriter.cs - Adapter tests:
tests/ConductorE.Api.Tests/AppTokenHttpGitHubIssueWriterTests.cs+HttpGitHubIssueWriterTests.cs(token-source seam) - State field:
src/ConductorE.Core/Domain/SelfImprovementSignatureState.cs(ConsecutiveCleanTicks)