feat: approval gates block indefinitely instead of auto-rejecting on timeout

Remove the wall-clock approval timeout that auto-rejected pending approvals.
The timeout modeled machine latency, but approvals are human latency
(unbounded); it also created an intent/outcome divergence where a human
approving at the same instant the timeout fired was silently overridden by
the auto-reject. Approvals now block until the operator decides; the
resolved-request dedup still guards against double-submit.

Add ServerMessage.ApprovalResolved and map ApprovalDecisionResolvedEvent to
it in DomainEventMapper so clients are notified when any approval is resolved
(by anyone) — letting a second connected client clear its prompt. This is the
event-sourced replacement for the removed timeout notification path.

Drop the now-dead ApprovalConfig (timeout_ms) and its loader/test/sample-config
references. ApprovalStatus.TIMED_OUT is retained for replay of historical events.
This commit is contained in:
2026-05-30 21:48:23 +04:00
parent 780a00229e
commit d3ce310100
11 changed files with 106 additions and 98 deletions
@@ -204,13 +204,11 @@ object ConfigLoader {
val serverSection = sections["server"] ?: emptyMap()
val tuiSection = sections["tui"] ?: emptyMap()
val cliSection = sections["cli"] ?: emptyMap()
val approvalSection = sections["approval"] ?: emptyMap()
val toolsSection = sections["tools"] ?: emptyMap()
val toolsShellSection = sections["tools.shell"] ?: emptyMap()
val toolsFileReadSection = sections["tools.file_read"] ?: emptyMap()
val toolsFileWriteSection = sections["tools.file_write"] ?: emptyMap()
val toolsFileEditSection = sections["tools.file_edit"] ?: emptyMap()
val routerSection = sections["router"] ?: emptyMap()
val routerEmbedderSection = sections["router.embedder"] ?: emptyMap()
val routerL3Section = sections["router.l3"] ?: emptyMap()
@@ -228,10 +226,6 @@ object ConfigLoader {
defaultOutput = asString(cliSection["default_output"], "human"),
)
val approval = ApprovalConfig(
timeoutMs = asLong(approvalSection["timeout_ms"], 300_000L),
)
// Resolve tool enable flags: prefer nested [tools.shell], [tools.file_read], etc.
// Fall back to flat [tools] section for backward compat
val shellEnabled = when {
@@ -350,7 +344,6 @@ object ConfigLoader {
server = server,
tui = tui,
cli = cli,
approval = approval,
tools = tools,
providers = providers,
router = router,
@@ -392,15 +385,6 @@ object ConfigLoader {
}
}
private fun asLong(value: Any?, default: Long = 0L): Long {
return when (value) {
is Long -> value
is Int -> value.toLong()
is String -> value.toLongOrNull() ?: default
else -> default
}
}
private fun asBoolean(value: Any?, default: Boolean = false): Boolean {
return when (value) {
is Boolean -> value
@@ -7,7 +7,6 @@ data class CorrexConfig(
val server: ServerConfig = ServerConfig(),
val tui: TuiConfig = TuiConfig(),
val cli: CliConfig = CliConfig(),
val approval: ApprovalConfig = ApprovalConfig(),
val tools: ToolsConfig = ToolsConfig(),
val providers: List<ProviderConfig> = emptyList(),
val router: RouterConfig = RouterConfig(),
@@ -30,11 +29,6 @@ data class CliConfig(
val defaultOutput: String = "human",
)
@Serializable
data class ApprovalConfig(
val timeoutMs: Long = 300_000L,
)
@Serializable
data class ToolsConfig(
val sandboxRoot: String = "",
@@ -12,7 +12,6 @@ class ConfigLoaderTest {
assertEquals("dark", config.tui.theme)
assertEquals(5, config.tui.sessionListLimit)
assertEquals("human", config.cli.defaultOutput)
assertEquals(300_000L, config.approval.timeoutMs)
}
@Test
@@ -28,9 +27,6 @@ class ConfigLoaderTest {
[cli]
default_output = "json"
[approval]
timeout_ms = 600000
""".trimIndent()
val loader = ConfigLoader::class.java
@@ -43,7 +39,6 @@ class ConfigLoaderTest {
assertEquals("light", result.tui.theme)
assertEquals(10, result.tui.sessionListLimit)
assertEquals("json", result.cli.defaultOutput)
assertEquals(600_000L, result.approval.timeoutMs)
}
@Test