From e247907b37c727b644941e81f517e4fed7e83e35 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Sun, 20 Sep 2026 23:37:31 +0800 Subject: [PATCH 1/9] test(acp): add lifecycle hardening RED cases --- .../acp/KimiAcpLifecycleHardeningTest.java | 138 ++++++++++++++++++ src/test/resources/fake-acp-agent.py | 65 ++++++--- 2 files changed, 179 insertions(+), 24 deletions(-) create mode 100644 src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java diff --git a/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java b/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java new file mode 100644 index 0000000..1d5fb26 --- /dev/null +++ b/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ +package io.github.easy4j.kimi.acp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import io.github.easy4j.kimi.KimiException; + +/** + * RED tests for the ACP lifecycle hardening OpenSpec change. + * + *

These tests intentionally target failure/race/resource cases that the + * current implementation does not yet satisfy. They must be committed before + * the production fix so CI provides evidence for the TDD RED phase.

+ */ +class KimiAcpLifecycleHardeningTest { + + private static final String FAKE_AGENT = Paths + .get("src", "test", "resources", "fake-acp-agent.py") + .toAbsolutePath().toString(); + + private static KimiAcpConfig config(String mode) { + KimiAcpConfig config = new KimiAcpConfig(); + config.setLocalExecutable("python3"); + config.setAcpSubcommand(null); + config.setAcpArgs(new String[] {FAKE_AGENT, mode}); + config.setConnectTimeoutMillis(2_000); + config.setReadTimeoutMillis(5_000); + return config; + } + + @Test + void shouldRemovePendingRpcWhenRequestCannotWrite() throws Exception { + try (KimiAcpClient client = new KimiAcpClient(config("normal"))) { + assertThrows(KimiException.class, () -> client.newSession("/tmp")); + assertEquals(0, privateMapSize(client, "pendingRpcs"), + "failed write before connect must not leave an orphaned RPC"); + } + } + + @Test + void shouldRejectSecondPromptForSameSessionBeforeSending() throws Exception { + try (KimiAcpClient client = new KimiAcpClient(config("delay-prompt"))) { + client.connect(); + String sessionId = client.newSession("/tmp"); + + CompletableFuture first = + client.promptAsync(sessionId, "first", null); + + assertThrows(KimiException.class, + () -> client.promptAsync(sessionId, "second", null), + "same session must not overwrite an active prompt stream"); + + client.cancel(sessionId); + first.cancel(true); + } + } + + @Test + void shouldIsolateThrowingDeltaCallbackAndKeepTransportUsable() throws Exception { + try (KimiAcpClient client = new KimiAcpClient(config("normal"))) { + client.connect(); + String sessionId = client.newSession("/tmp"); + + CompletableFuture first = + client.promptAsync(sessionId, "callback-fails", delta -> { + throw new IllegalStateException("listener boom"); + }); + + ExecutionException failure = assertThrows(ExecutionException.class, + () -> first.get(2, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof KimiException); + assertTrue(failure.getCause().getMessage().contains("callback")); + + List deltas = new ArrayList(); + KimiAcpTurnResult second = client.prompt("sess_after_callback", "still-alive", deltas::add); + assertEquals("你好世界", second.getContent()); + assertEquals(2, deltas.size()); + } + } + + @Test + void shouldFailPendingPromptOnMalformedJsonFrameInsteadOfTimingOut() throws Exception { + try (KimiAcpClient client = new KimiAcpClient(config("malformed-prompt"))) { + client.connect(); + String sessionId = client.newSession("/tmp"); + + CompletableFuture future = + client.promptAsync(sessionId, "bad-frame", null); + + ExecutionException failure = assertThrows(ExecutionException.class, + () -> future.get(2, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof KimiException); + assertTrue(failure.getCause().getMessage().toLowerCase().contains("json") + || failure.getCause().getMessage().toLowerCase().contains("protocol"), + "malformed ACP frame must become a protocol failure"); + } + } + + @Test + void shouldFailPendingPromptWhenAgentProcessExits() throws Exception { + try (KimiAcpClient client = new KimiAcpClient(config("exit-on-prompt"))) { + client.connect(); + String sessionId = client.newSession("/tmp"); + + CompletableFuture future = + client.promptAsync(sessionId, "exit", null); + + ExecutionException failure = assertThrows(ExecutionException.class, + () -> future.get(2, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof KimiException); + } + } + + @SuppressWarnings("unchecked") + private static int privateMapSize(KimiAcpClient client, String fieldName) throws Exception { + Field field = KimiAcpClient.class.getDeclaredField(fieldName); + field.setAccessible(true); + return ((Map) field.get(client)).size(); + } +} diff --git a/src/test/resources/fake-acp-agent.py b/src/test/resources/fake-acp-agent.py index 281ba19..26a69ff 100755 --- a/src/test/resources/fake-acp-agent.py +++ b/src/test/resources/fake-acp-agent.py @@ -1,20 +1,22 @@ #!/usr/bin/env python3 -"""Fake Kimi ACP agent for end-to-end tests. +"""Fake Kimi ACP agent for end-to-end and lifecycle-hardening tests. -Speaks newline-delimited JSON-RPC on stdio exactly like `kimi acp`: -answers `initialize`, `session/new`, `session/list`, `session/fork`, -`session/load`, `session/resume`, `session/close`, `session/delete`, -`authenticate`, `logout`, `session/set_model`, `session/set_mode`; on -`session/prompt` streams a few `session/update` notifications (one unknown -kind, one agent_message_chunk, another agent_message_chunk) and answers with -stop_reason `end_turn`. `session/cancel` notifications are ignored. +Optional first argument selects behavior: + normal normal ACP replies + delay-prompt hold a prompt open long enough to test same-session admission + malformed-prompt emit malformed JSON then stay alive + exit-on-prompt exit the process while a prompt is pending """ import json import sys +import time + + +MODE = sys.argv[1] if len(sys.argv) > 1 else "normal" def send(payload): - sys.stdout.write(json.dumps(payload) + "\n") + sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n") sys.stdout.flush() @@ -22,6 +24,24 @@ def reply(req_id, result): send({"jsonrpc": "2.0", "id": req_id, "result": result}) +def send_normal_prompt(session_id, req_id): + send({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": session_id, + "update": {"sessionUpdate": "tool_call", "title": "ignored"}, + }}) + send({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": session_id, + "update": {"sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": "你好"}}, + }}) + send({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": session_id, + "update": {"sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": "世界"}}, + }}) + reply(req_id, {"stopReason": "end_turn"}) + + def main(): for line in sys.stdin: line = line.strip() @@ -31,6 +51,7 @@ def main(): frame = json.loads(line) except ValueError: continue + method = frame.get("method", "") req_id = frame.get("id") params = frame.get("params") or {} @@ -53,21 +74,17 @@ def main(): reply(req_id, {}) elif method == "session/prompt": session_id = params.get("sessionId", "sess_fake") - send({"jsonrpc": "2.0", "method": "session/update", "params": { - "sessionId": session_id, - "update": {"sessionUpdate": "tool_call", "title": "ignored"}, - }}) - send({"jsonrpc": "2.0", "method": "session/update", "params": { - "sessionId": session_id, - "update": {"sessionUpdate": "agent_message_chunk", - "content": {"type": "text", "text": "你好"}}, - }}) - send({"jsonrpc": "2.0", "method": "session/update", "params": { - "sessionId": session_id, - "update": {"sessionUpdate": "agent_message_chunk", - "content": {"type": "text", "text": "世界"}}, - }}) - reply(req_id, {"stopReason": "end_turn"}) + if MODE == "delay-prompt": + time.sleep(3) + send_normal_prompt(session_id, req_id) + elif MODE == "malformed-prompt": + sys.stdout.write("{not-json\n") + sys.stdout.flush() + time.sleep(5) + elif MODE == "exit-on-prompt": + sys.exit(7) + else: + send_normal_prompt(session_id, req_id) elif method == "session/cancel": pass elif req_id is not None: From 24a51acd038f554438f115ce2d124149f432bd2f Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Sun, 20 Sep 2026 23:39:33 +0800 Subject: [PATCH 2/9] fix(acp): harden pending RPC and prompt lifecycle --- .../github/easy4j/kimi/acp/KimiAcpClient.java | 65 ++++++++++++++++--- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java b/src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java index daa88ff..d57ff05 100644 --- a/src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java +++ b/src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java @@ -299,7 +299,10 @@ public CompletableFuture promptAsync(String sessionId, String throw new IllegalStateException("kimi acp client is closed"); } PromptStream stream = new PromptStream(sessionId, onDelta); - promptStreams.put(sessionId, stream); + PromptStream active = promptStreams.putIfAbsent(sessionId, stream); + if (active != null) { + throw new KimiException("kimi acp session already has an active prompt: " + sessionId); + } Map content = new LinkedHashMap(); content.put("type", "text"); content.put("text", text); @@ -308,14 +311,24 @@ public CompletableFuture promptAsync(String sessionId, String Map params = new LinkedHashMap(); params.put("sessionId", sessionId); params.put("prompt", blocks); - CompletableFuture response = request("session/prompt", params); + final CompletableFuture response; + try { + response = request("session/prompt", params); + } catch (RuntimeException e) { + promptStreams.remove(sessionId, stream); + throw e; + } CompletableFuture future = response.thenApply(node -> { + RuntimeException callbackFailure = stream.callbackFailure(); + if (callbackFailure != null) { + throw new KimiException("kimi acp prompt callback failed", callbackFailure); + } String stopReason = firstText(node, "stopReason", "stop_reason"); return new KimiAcpTurnResult(sessionId, stopReason, stream.content()); }); scheduleTimeout(future, config.getReadTimeoutMillis(), "session/prompt turn"); future.whenComplete((r, error) -> { - promptStreams.remove(sessionId); + promptStreams.remove(sessionId, stream); if (error != null) { response.completeExceptionally(error); } @@ -427,8 +440,15 @@ public void close() { if (!closed.compareAndSet(false, true)) { return; } + connected.set(false); timer.shutdownNow(); + PrintWriter writer = stdin; + stdin = null; + if (writer != null) { + writer.close(); + } Process current = process; + process = null; if (current != null) { current.destroy(); } @@ -447,8 +467,16 @@ private CompletableFuture request(String method, Map p payload.put("method", method); payload.put("params", params); CompletableFuture future = new CompletableFuture(); - pendingRpcs.put(Long.valueOf(id), future); - writeJson(payload); + Long rpcId = Long.valueOf(id); + pendingRpcs.put(rpcId, future); + future.whenComplete((result, error) -> pendingRpcs.remove(rpcId, future)); + try { + writeJson(payload); + } catch (RuntimeException e) { + pendingRpcs.remove(rpcId, future); + future.completeExceptionally(e); + throw e; + } return future; } @@ -508,6 +536,16 @@ private void readLoop() { if (!closed.get()) { failAllPending(new KimiException("kimi acp stdout read failed", e)); } + } catch (KimiException e) { + if (!closed.get()) { + failAllPending(e); + Process current = process; + if (current != null) { + current.destroy(); + } + } + } finally { + connected.set(false); } } @@ -516,8 +554,7 @@ private void handleFrame(String frame) { try { node = mapper.readTree(frame); } catch (Exception ex) { - log.warn("Ignored non-JSON frame from kimi acp"); - return; + throw new KimiException("kimi acp protocol received invalid JSON frame", ex); } if (node.hasNonNull("id")) { CompletableFuture pending = pendingRpcs.remove(Long.valueOf(node.get("id").asLong())); @@ -631,6 +668,7 @@ private final class PromptStream { private final StringBuilder content = new StringBuilder(); private final Consumer onDelta; private boolean truncationWarned; + private volatile RuntimeException callbackFailure; PromptStream(String sessionId, Consumer onDelta) { this.onDelta = onDelta; @@ -653,11 +691,20 @@ void append(String text) { } } content.append(applied); - if (!applied.isEmpty() && onDelta != null) { - onDelta.accept(applied); + if (!applied.isEmpty() && onDelta != null && callbackFailure == null) { + try { + onDelta.accept(applied); + } catch (RuntimeException e) { + callbackFailure = e; + log.warn("kimi acp prompt callback failed; transport remains active"); + } } } + RuntimeException callbackFailure() { + return callbackFailure; + } + String content() { return content.toString(); } From 8d1e8685962e62a4fd86e9d3bb2b627ac840170a Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Sun, 20 Sep 2026 23:41:41 +0800 Subject: [PATCH 3/9] test(acp): add cancel and terminal cleanup RED cases --- .../acp/KimiAcpLifecycleHardeningTest.java | 49 +++++++++++++++++++ src/test/resources/fake-acp-agent.py | 3 ++ 2 files changed, 52 insertions(+) diff --git a/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java b/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java index 1d5fb26..12d3c8a 100644 --- a/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java +++ b/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java @@ -129,6 +129,55 @@ void shouldFailPendingPromptWhenAgentProcessExits() throws Exception { } } + + @Test + void shouldCompletePromptAsCancelledAndReleaseRegistries() throws Exception { + try (KimiAcpClient client = new KimiAcpClient(config("delay-prompt"))) { + client.connect(); + String sessionId = client.newSession("/tmp"); + + CompletableFuture future = + client.promptAsync(sessionId, "cancel-me", null); + client.cancel(sessionId); + + ExecutionException failure = assertThrows(ExecutionException.class, + () -> future.get(1, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof KimiException); + assertTrue(failure.getCause().getMessage().toLowerCase().contains("cancel")); + assertEquals(0, privateMapSize(client, "pendingRpcs")); + assertEquals(0, privateMapSize(client, "promptStreams")); + } + } + + @Test + void shouldRemoveTimedOutRpcFromPendingRegistry() throws Exception { + KimiAcpConfig config = config("hang-list"); + config.setConnectTimeoutMillis(250); + try (KimiAcpClient client = new KimiAcpClient(config)) { + client.connect(); + + assertThrows(KimiException.class, client::listSessions); + assertEquals(0, privateMapSize(client, "pendingRpcs"), + "timed out RPC must be removed from the pending registry"); + } + } + + @Test + void shouldFailAndCleanPendingPromptWhenClientCloses() throws Exception { + KimiAcpClient client = new KimiAcpClient(config("delay-prompt")); + client.connect(); + String sessionId = client.newSession("/tmp"); + CompletableFuture future = + client.promptAsync(sessionId, "close-me", null); + + client.close(); + + assertThrows(ExecutionException.class, () -> future.get(1, TimeUnit.SECONDS)); + assertEquals(0, privateMapSize(client, "pendingRpcs")); + assertEquals(0, privateMapSize(client, "promptStreams")); + client.close(); + } + @SuppressWarnings("unchecked") private static int privateMapSize(KimiAcpClient client, String fieldName) throws Exception { Field field = KimiAcpClient.class.getDeclaredField(fieldName); diff --git a/src/test/resources/fake-acp-agent.py b/src/test/resources/fake-acp-agent.py index 26a69ff..f8b53e4 100755 --- a/src/test/resources/fake-acp-agent.py +++ b/src/test/resources/fake-acp-agent.py @@ -6,6 +6,7 @@ delay-prompt hold a prompt open long enough to test same-session admission malformed-prompt emit malformed JSON then stay alive exit-on-prompt exit the process while a prompt is pending + hang-list never answer session/list """ import json import sys @@ -69,6 +70,8 @@ def main(): reply(req_id, {"sessionId": params.get("sessionId", "sess_fake")}) elif method == "session/fork": reply(req_id, {"sessionId": "sess_forked"}) + elif method == "session/list" and MODE == "hang-list": + time.sleep(5) elif method in ("session/list", "session/set_mode", "session/set_model", "authenticate", "logout", "session/close", "session/delete"): reply(req_id, {}) From 173d3f70be51822472d83c4c230b0c9ec826bb8f Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Sun, 20 Sep 2026 23:43:10 +0800 Subject: [PATCH 4/9] fix(acp): complete active prompt on cancel --- .../github/easy4j/kimi/acp/KimiAcpClient.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java b/src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java index d57ff05..897b302 100644 --- a/src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java +++ b/src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java @@ -326,6 +326,7 @@ public CompletableFuture promptAsync(String sessionId, String String stopReason = firstText(node, "stopReason", "stop_reason"); return new KimiAcpTurnResult(sessionId, stopReason, stream.content()); }); + stream.bind(future); scheduleTimeout(future, config.getReadTimeoutMillis(), "session/prompt turn"); future.whenComplete((r, error) -> { promptStreams.remove(sessionId, stream); @@ -372,6 +373,10 @@ public void cancel(String sessionId) { Map params = new LinkedHashMap(); params.put("sessionId", sessionId); notify("session/cancel", params); + PromptStream stream = promptStreams.get(sessionId); + if (stream != null) { + stream.cancel(); + } } /** @@ -665,15 +670,30 @@ private String firstText(JsonNode node, String... fields) { */ private final class PromptStream { + private final String sessionId; private final StringBuilder content = new StringBuilder(); private final Consumer onDelta; private boolean truncationWarned; private volatile RuntimeException callbackFailure; + private volatile CompletableFuture turnFuture; PromptStream(String sessionId, Consumer onDelta) { + this.sessionId = sessionId; this.onDelta = onDelta; } + void bind(CompletableFuture future) { + this.turnFuture = future; + } + + void cancel() { + CompletableFuture future = turnFuture; + if (future != null) { + future.completeExceptionally( + new KimiException("kimi acp prompt cancelled: " + sessionId)); + } + } + void append(String text) { int cap = config.getMaxContentChars(); String applied = text; From 4ceb2460f522a056edae62135a5b237dfadb48df Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Sun, 20 Sep 2026 23:44:52 +0800 Subject: [PATCH 5/9] test(acp): add observable lifecycle RED cases --- .../acp/KimiAcpLifecycleHardeningTest.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java b/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java index 12d3c8a..185b2c8 100644 --- a/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java +++ b/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java @@ -11,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; @@ -178,6 +179,52 @@ void shouldFailAndCleanPendingPromptWhenClientCloses() throws Exception { client.close(); } + + @Test + void shouldExposeLifecycleStateTransitions() throws Exception { + KimiAcpClient client = new KimiAcpClient(config("normal")); + assertEquals("NEW", lifecycleState(client)); + + client.connect(); + assertEquals("READY", lifecycleState(client)); + + client.close(); + assertEquals("CLOSED", lifecycleState(client)); + } + + @Test + void shouldMarkTransportFailedAndRejectNewRpcAfterMalformedFrame() throws Exception { + try (KimiAcpClient client = new KimiAcpClient(config("malformed-prompt"))) { + client.connect(); + String sessionId = client.newSession("/tmp"); + + CompletableFuture future = + client.promptAsync(sessionId, "bad-frame-state", null); + assertThrows(ExecutionException.class, () -> future.get(2, TimeUnit.SECONDS)); + + assertEquals("FAILED", lifecycleState(client)); + assertThrows(KimiException.class, client::listSessions, + "a failed transport must reject new RPCs before registration"); + assertEquals(0, privateMapSize(client, "pendingRpcs")); + } + } + + @Test + void shouldReturnToNewAfterRecoverableConnectFailure() throws Exception { + KimiAcpConfig bad = config("normal"); + bad.setLocalExecutable("/nonexistent/kimi"); + try (KimiAcpClient client = new KimiAcpClient(bad)) { + assertThrows(KimiException.class, client::connect); + assertEquals("NEW", lifecycleState(client), + "spawn/initialize failure remains retryable on the same client"); + } + } + + private static String lifecycleState(KimiAcpClient client) throws Exception { + Method method = KimiAcpClient.class.getMethod("getState"); + return String.valueOf(method.invoke(client)); + } + @SuppressWarnings("unchecked") private static int privateMapSize(KimiAcpClient client, String fieldName) throws Exception { Field field = KimiAcpClient.class.getDeclaredField(fieldName); From 409b7cc314237bfe1df4955a57ff90388e83197b Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Sun, 20 Sep 2026 23:46:33 +0800 Subject: [PATCH 6/9] feat(acp): expose lifecycle state and failed transport guard --- .../github/easy4j/kimi/acp/KimiAcpClient.java | 41 +++++++++++++++++-- .../github/easy4j/kimi/acp/KimiAcpState.java | 22 ++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 src/main/java/io/github/easy4j/kimi/acp/KimiAcpState.java diff --git a/src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java b/src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java index 897b302..0623ec6 100644 --- a/src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java +++ b/src/main/java/io/github/easy4j/kimi/acp/KimiAcpClient.java @@ -34,6 +34,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.slf4j.Logger; @@ -80,6 +81,8 @@ public class KimiAcpClient implements AutoCloseable { private final AtomicLong rpcIds = new AtomicLong(); private final AtomicBoolean closed = new AtomicBoolean(false); private final AtomicBoolean connected = new AtomicBoolean(false); + private final AtomicReference state = + new AtomicReference(KimiAcpState.NEW); private final ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(r -> { Thread thread = new Thread(r, "kimi-acp-timer"); thread.setDaemon(true); @@ -117,6 +120,7 @@ public String connect() { if (!connected.compareAndSet(false, true)) { throw new IllegalStateException("kimi acp client is already connected"); } + state.set(KimiAcpState.CONNECTING); List command = new ArrayList(); command.add(config.getLocalExecutable()); if (config.getAcpSubcommand() != null) { @@ -131,8 +135,10 @@ public String connect() { builder.redirectErrorStream(false); try { process = builder.start(); + state.set(KimiAcpState.INITIALIZING); } catch (IOException e) { connected.set(false); + state.set(KimiAcpState.NEW); throw new KimiException("Failed to spawn kimi acp: " + config.getLocalExecutable(), e); } stdin = new PrintWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8), true); @@ -152,6 +158,7 @@ public String connect() { if (result.hasNonNull("agentInfo")) { agentVersion = result.path("agentInfo").path("version").asText(null); } + state.set(KimiAcpState.READY); return agentVersion; } catch (RuntimeException e) { // Handshake failure leaves the child alive — destroy it here so a @@ -163,6 +170,7 @@ public String connect() { process = null; stdin = null; connected.set(false); + state.set(KimiAcpState.NEW); throw e; } } @@ -436,6 +444,15 @@ public boolean isClosed() { return closed.get(); } + /** + * Returns the current ACP lifecycle state. + * + * @return the lifecycle state; never {@code null}. + */ + public KimiAcpState getState() { + return state.get(); + } + /** * Terminates the {@code kimi acp} child process and releases the timer. * Idempotent. @@ -445,6 +462,7 @@ public void close() { if (!closed.compareAndSet(false, true)) { return; } + state.set(KimiAcpState.CLOSING); connected.set(false); timer.shutdownNow(); PrintWriter writer = stdin; @@ -458,6 +476,7 @@ public void close() { current.destroy(); } failAllPending(new KimiException("kimi acp client closed")); + state.set(KimiAcpState.CLOSED); } // ============================================================ @@ -465,6 +484,10 @@ public void close() { // ============================================================ private CompletableFuture request(String method, Map params) { + KimiAcpState currentState = state.get(); + if (!"initialize".equals(method) && currentState != KimiAcpState.READY) { + throw new KimiException("kimi acp client is not ready: state=" + currentState); + } long id = rpcIds.incrementAndGet(); Map payload = new LinkedHashMap(); payload.put("jsonrpc", "2.0"); @@ -530,20 +553,22 @@ private void readLoop() { KimiException error = new KimiException( "kimi acp frame exceeded maxFrameChars=" + config.getMaxFrameChars()); log.warn("kimi acp frame over cap, tearing transport down"); - failAllPending(error); + failTransport(error); process.destroy(); return; } handleFrame(line); } - failAllPending(new KimiException("kimi acp stdout closed (child exited)")); + if (!closed.get()) { + failTransport(new KimiException("kimi acp stdout closed (child exited)")); + } } catch (IOException e) { if (!closed.get()) { - failAllPending(new KimiException("kimi acp stdout read failed", e)); + failTransport(new KimiException("kimi acp stdout read failed", e)); } } catch (KimiException e) { if (!closed.get()) { - failAllPending(e); + failTransport(e); Process current = process; if (current != null) { current.destroy(); @@ -631,6 +656,14 @@ private JsonNode await(CompletableFuture future, long timeoutMillis, S } } + private void failTransport(KimiException error) { + if (!closed.get()) { + state.set(KimiAcpState.FAILED); + } + connected.set(false); + failAllPending(error); + } + private void failAllPending(KimiException error) { for (Map.Entry> entry : pendingRpcs.entrySet()) { CompletableFuture future = pendingRpcs.remove(entry.getKey()); diff --git a/src/main/java/io/github/easy4j/kimi/acp/KimiAcpState.java b/src/main/java/io/github/easy4j/kimi/acp/KimiAcpState.java new file mode 100644 index 0000000..53a5701 --- /dev/null +++ b/src/main/java/io/github/easy4j/kimi/acp/KimiAcpState.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-present, easy-4-java (https://github.com/easy-4-java). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ +package io.github.easy4j.kimi.acp; + +/** + * Observable lifecycle states of a {@link KimiAcpClient}. + * + * @since 3.0.0 + */ +public enum KimiAcpState { + NEW, + CONNECTING, + INITIALIZING, + READY, + CLOSING, + CLOSED, + FAILED +} From 53e02ad9facca53314a746f2b0a39fea35cf4afb Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Sun, 20 Sep 2026 23:53:53 +0800 Subject: [PATCH 7/9] docs(openspec): record ACP lifecycle review --- .../kimi-acp-lifecycle-hardening/review.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 openspec/changes/kimi-acp-lifecycle-hardening/review.md diff --git a/openspec/changes/kimi-acp-lifecycle-hardening/review.md b/openspec/changes/kimi-acp-lifecycle-hardening/review.md new file mode 100644 index 0000000..7eb781c --- /dev/null +++ b/openspec/changes/kimi-acp-lifecycle-hardening/review.md @@ -0,0 +1,52 @@ +# kimi-acp-lifecycle-hardening 评审记录 + +## Decision + +**APPROVED FOR IMPLEMENTATION / IN PROGRESS** + +用户于 2026-09-20 明确要求进入 OpenSpec 评审、先执行 8 个 Change 的 strict validate,并从本 Change 开始严格 TDD 实现。 + +本记录只批准 `kimi-acp-lifecycle-hardening` 已书面定义的范围,不扩展到 Runtime Foundation、Typed Protocol、WebSocket 或其他 Change。 + +## Specification Gate + +- OpenSpec CLI: `1.13.1` +- GitHub Actions run: `35519997399` +- Job: `106102324513` +- Command: + `openspec validate kimi-acp-lifecycle-hardening --type change --strict --no-interactive` +- Result: `Change 'kimi-acp-lifecycle-hardening' is valid` +- Status: **PASS** + +其余 7 个 Change 在同一 matrix run 中也均 strict validate PASS;详见 evidence.md。 + +## TDD Review + +实现严格分为测试提交与生产代码提交,RED 失败证据在修复前由 CI 产生: + +1. RED-1 `e247907b...` → 4 个预期失败。 +2. GREEN-1 `24a51acd...` → 72/72 tests PASS。 +3. RED-2 `8d1e8685...` → cancel 终态 1 个预期失败。 +4. GREEN-2 `173d3f70...` → CI PASS。 +5. RED-3 `4ceb2460...` → lifecycle state 3 个预期 error。 +6. GREEN-3 `409b7cc3...` → 78/78 tests PASS。 + +三条兼容线随后运行相同 hardening tests: +- Java 21 / Jackson 3:PASS +- Java 17 / Jackson 2:PASS +- Java 8 / Jackson 2:PASS + +## Remaining Gates + +以下内容尚未完成,因此本 Change 仍为 **IN PROGRESS**,不得宣称全部 production-ready: + +- response/timeout/cancel/close 的 barrier 级高并发 race 压测; +- 不同 session 的并发 turn isolation 压测; +- stubborn child process 的有界 graceful/force shutdown; +- 线程、timer、process 的重复循环 leak baseline; +- hardening 后重新运行 CodeGraph impact review; +- 最终三线合并后的 CI 与 workspace/release hygiene。 + +## Approval Boundary + +当前允许继续完成上述剩余任务,并在证据齐全后把三个兼容线 PR 转 Ready/合并。 From 5c10df2078b805e7747e2329c5c004e56ee4c680 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Sun, 20 Sep 2026 23:53:55 +0800 Subject: [PATCH 8/9] docs(openspec): record ACP lifecycle evidence --- .../kimi-acp-lifecycle-hardening/evidence.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 openspec/changes/kimi-acp-lifecycle-hardening/evidence.md diff --git a/openspec/changes/kimi-acp-lifecycle-hardening/evidence.md b/openspec/changes/kimi-acp-lifecycle-hardening/evidence.md new file mode 100644 index 0000000..a089dd3 --- /dev/null +++ b/openspec/changes/kimi-acp-lifecycle-hardening/evidence.md @@ -0,0 +1,199 @@ +# kimi-acp-lifecycle-hardening 验证证据 + +## 1. OpenSpec Strict Validation + +Workflow commit: `1a810365662c9c445ea1ff554ee211c396c82bbb` + +Workflow run: `35519997399` + +Pinned CLI: `@fission-ai/openspec@1.13.1` + +全部 8 个 Change 均执行: + +```text +openspec validate --type change --strict --no-interactive +``` + +结果: + +| Change | Job | Result | +|---|---:|---| +| kimi-runtime-foundation | 106102324518 | PASS | +| kimi-acp-lifecycle-hardening | 106102324513 | PASS | +| kimi-typed-protocol | 106102324502 | PASS | +| kimi-unified-events | 106102324343 | PASS | +| kimi-server-websocket | 106102324517 | PASS | +| kimi-process-hardening | 106102324484 | PASS | +| kimi-observability | 106102324551 | PASS | +| kimi-branch-parity | 106102324475 | PASS | + +每个 job 日志均记录 `openspec --version = 1.13.1` 和 `Change '' is valid`。 + +## 2. CodeGraph Baseline + +实现前使用 CodeGraph v1.6.0 实际索引: + +| Branch | Files | Nodes | Edges | +|---|---:|---:|---:| +| feature/1.0.x | 25 | 609 | 1,283 | +| feature/2.0.x | 25 | 608 | 1,278 | +| feature/3.0.x | 25 | 608 | 1,278 | + +关键调用基线已检查: +- `KimiAcpClient.promptAsync` → request / scheduleTimeout / PromptStream / KimiAcpTurnResult +- `KimiAcpClient.request` ← initialize/session/prompt/session management callers +- ACP reader / pending RPC / promptStreams 为生命周期核心资源。 + +**Post-hardening CodeGraph impact:NOT_RUN**。当前执行环境没有可用本地 Runner,因此不得把 baseline 冒充最终 impact review。 + +## 3. TDD Cycle 1 + +### RED + +Commit: `e247907b37c727b644941e81f517e4fed7e83e35` + +CI run/job: `35520185628 / 106102809432` + +Result: **BUILD FAILURE** + +```text +Tests run: 72, Failures: 4, Errors: 0 +``` + +失败事实: +- callback exception 杀死 reader,future 变为 TimeoutException; +- malformed JSON 被忽略,future 变为 TimeoutException; +- write-before-connect 后 pendingRpcs expected 0 but was 1; +- same-session 第二个 prompt 未被拒绝。 + +### GREEN + +Commit: `24a51acd038f554438f115ce2d124149f432bd2f` + +CI run/job: `35520278533 / 106103048600` + +```text +KimiAcpLifecycleHardeningTest: 5/5 PASS +Tests run: 72, Failures: 0, Errors: 0 +BUILD SUCCESS +``` + +## 4. TDD Cycle 2 + +### RED + +Commit: `8d1e8685962e62a4fd86e9d3bb2b627ac840170a` + +CI run/job: `35520389264 / 106103337398` + +```text +Tests run: 75, Failures: 1 +``` + +唯一失败:`cancel` 只发送 notification,没有终结 active prompt;测试等待后得到 TimeoutException。 + +### GREEN + +Commit: `173d3f70be51822472d83c4c230b0c9ec826bb8f` + +CI run/job: `35520462943 / 106103531738` + +Result: **PASS** + +实现 cancel → active turn exceptional completion,并由既有 completion cleanup 移除 pending RPC / prompt stream。 + +## 5. TDD Cycle 3 + +### RED + +Commit: `4ceb2460f522a056edae62135a5b237dfadb48df` + +CI run/job: `35520544960 / 106103751629` + +```text +Tests run: 78, Failures: 0, Errors: 3 +``` + +三个 error 全部为: +`NoSuchMethodException: KimiAcpClient.getState()` + +### GREEN + +Commit: `409b7cc314237bfe1df4955a57ff90388e83197b` + +CI run/job: `35520636076 / 106103986308` + +```text +KimiAcpLifecycleHardeningTest: 11/11 PASS +Tests run: 78, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` + +新增 `KimiAcpState`: +NEW → CONNECTING → INITIALIZING → READY → CLOSING → CLOSED,transport fatal failure → FAILED。 + +FAILED transport 会在注册新 RPC 前拒绝调用。 + +## 6. Compatibility Line Evidence + +### feature/3.0.x implementation line + +JDK: 21 +Branch: `hardening/3.0.x-acp-lifecycle` +Head validated: `409b7cc314237bfe1df4955a57ff90388e83197b` + +```text +KimiAcpLifecycleHardeningTest: 11/11 PASS +Full suite: 78/78 PASS +BUILD SUCCESS +``` + +### feature/2.0.x compatibility line + +PR: #2 +Head: `9361053e3a65fae325182a9e51247ba27e1e5640` +CI run/job: `35520872348 / 106104609921` +JDK: Temurin 17.0.20 + +```text +KimiAcpLifecycleHardeningTest: 11/11 PASS +Full suite: 78/78 PASS +BUILD SUCCESS +``` + +### feature/1.0.x compatibility line + +PR: #3 +Head: `2606afe39e882aaed2f6f2e1f22bc52c35c4def5` +CI run/job: `35520900971 / 106104684383` +JDK: Temurin 8u504 + +```text +KimiAcpLifecycleHardeningTest: 11/11 PASS +Full suite: 78/78 PASS +BUILD SUCCESS +``` + +## 7. Current Semantic Changes + +已验证: +- failed write 不残留 pending RPC; +- same-session active prompt 不被覆盖; +- callback exception 不再杀死 ACP reader; +- malformed JSON 变为明确 protocol failure; +- process exit 使 pending prompt 失败; +- timeout/close 清理 pending registries; +- cancel 立即给 active prompt 单一异常终态; +- lifecycle state 可观察; +- transport fatal failure 进入 FAILED,并拒绝新 RPC; +- recoverable connect/spawn failure 回到 NEW; +- close 幂等。 + +## 8. Evidence Gaps + +仍为 NOT_RUN / NOT_PROVEN: +- 高并发 barrier race; +- stubborn process force-kill; +- 多轮 thread/timer/process leak baseline; +- post-change CodeGraph impact; +- dependency vulnerability/static analysis(本 Change 最终 production gate)。 From 58ae6fa3d293c96d0fd6a631c3da52d0b8aa2ec0 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Sun, 20 Sep 2026 23:55:01 +0800 Subject: [PATCH 9/9] test(acp): add concurrency timeout and stubborn-close cases --- .../acp/KimiAcpLifecycleHardeningTest.java | 68 +++++++++++++++++++ src/test/resources/fake-acp-agent.py | 47 ++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java b/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java index 185b2c8..97a7270 100644 --- a/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java +++ b/src/test/java/io/github/easy4j/kimi/acp/KimiAcpLifecycleHardeningTest.java @@ -12,12 +12,14 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; @@ -220,6 +222,72 @@ void shouldReturnToNewAfterRecoverableConnectFailure() throws Exception { } } + + @Test + void shouldCleanPromptRegistriesAfterPromptTimeout() throws Exception { + KimiAcpConfig config = config("hang-prompt"); + config.setReadTimeoutMillis(200); + try (KimiAcpClient client = new KimiAcpClient(config)) { + client.connect(); + String sessionId = client.newSession("/tmp"); + + CompletableFuture future = + client.promptAsync(sessionId, "timeout", null); + + ExecutionException failure = assertThrows(ExecutionException.class, + () -> future.get(2, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof KimiException); + assertTrue(failure.getCause().getMessage().toLowerCase().contains("timed out")); + assertEquals(0, privateMapSize(client, "pendingRpcs")); + assertEquals(0, privateMapSize(client, "promptStreams")); + } + } + + @Test + void shouldKeepConcurrentSessionsIsolated() throws Exception { + try (KimiAcpClient client = new KimiAcpClient(config("concurrent-prompts"))) { + client.connect(); + + List firstDeltas = java.util.Collections.synchronizedList(new ArrayList()); + List secondDeltas = java.util.Collections.synchronizedList(new ArrayList()); + + CompletableFuture first = + client.promptAsync("session-A", "A", firstDeltas::add); + CompletableFuture second = + client.promptAsync("session-B", "B", secondDeltas::add); + + KimiAcpTurnResult firstResult = first.get(2, TimeUnit.SECONDS); + KimiAcpTurnResult secondResult = second.get(2, TimeUnit.SECONDS); + + assertEquals("A-1A-2", firstResult.getContent()); + assertEquals("B-1B-2", secondResult.getContent()); + assertEquals(java.util.Arrays.asList("A-1", "A-2"), firstDeltas); + assertEquals(java.util.Arrays.asList("B-1", "B-2"), secondDeltas); + assertEquals(0, privateMapSize(client, "promptStreams")); + } + } + + @Test + void shouldForceTerminateStubbornOwnedProcessOnClose() throws Exception { + KimiAcpClient client = new KimiAcpClient(config("stubborn-close")); + client.connect(); + Process child = privateProcess(client); + assertTrue(child.isAlive()); + + client.close(); + + assertTrue(child.waitFor(1, TimeUnit.SECONDS), + "close must force-terminate an owned ACP process that ignores graceful termination"); + assertTrue(!child.isAlive()); + assertEquals("CLOSED", lifecycleState(client)); + } + + private static Process privateProcess(KimiAcpClient client) throws Exception { + Field field = KimiAcpClient.class.getDeclaredField("process"); + field.setAccessible(true); + return (Process) field.get(client); + } + private static String lifecycleState(KimiAcpClient client) throws Exception { Method method = KimiAcpClient.class.getMethod("getState"); return String.valueOf(method.invoke(client)); diff --git a/src/test/resources/fake-acp-agent.py b/src/test/resources/fake-acp-agent.py index f8b53e4..9dfc59f 100755 --- a/src/test/resources/fake-acp-agent.py +++ b/src/test/resources/fake-acp-agent.py @@ -7,18 +7,28 @@ malformed-prompt emit malformed JSON then stay alive exit-on-prompt exit the process while a prompt is pending hang-list never answer session/list + hang-prompt never answer session/prompt + concurrent-prompts answer two sessions from background threads + stubborn-close ignore SIGTERM and stay alive after stdin EOF """ import json import sys import time +import signal +import threading MODE = sys.argv[1] if len(sys.argv) > 1 else "normal" +WRITE_LOCK = threading.Lock() + +if MODE == "stubborn-close": + signal.signal(signal.SIGTERM, signal.SIG_IGN) def send(payload): - sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n") - sys.stdout.flush() + with WRITE_LOCK: + sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n") + sys.stdout.flush() def reply(req_id, result): @@ -43,7 +53,25 @@ def send_normal_prompt(session_id, req_id): reply(req_id, {"stopReason": "end_turn"}) +def send_concurrent_prompt(session_id, req_id): + if session_id.endswith("A"): + time.sleep(0.08) + pieces = ["A-1", "A-2"] + else: + time.sleep(0.02) + pieces = ["B-1", "B-2"] + for piece in pieces: + send({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": session_id, + "update": {"sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": piece}}, + }}) + time.sleep(0.01) + reply(req_id, {"stopReason": "end_turn"}) + + def main(): + workers = [] for line in sys.stdin: line = line.strip() if not line: @@ -86,6 +114,14 @@ def main(): time.sleep(5) elif MODE == "exit-on-prompt": sys.exit(7) + elif MODE == "hang-prompt": + time.sleep(5) + elif MODE == "concurrent-prompts": + worker = threading.Thread(target=send_concurrent_prompt, + args=(session_id, req_id)) + worker.daemon = True + worker.start() + workers.append(worker) else: send_normal_prompt(session_id, req_id) elif method == "session/cancel": @@ -94,6 +130,13 @@ def main(): send({"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": "method not found: " + method}}) + for worker in workers: + worker.join(timeout=1) + + if MODE == "stubborn-close": + while True: + time.sleep(1) + if __name__ == "__main__": main()