From 75a226fdebfbe4e05e2880690b39dbe689c2f89d Mon Sep 17 00:00:00 2001 From: Cheun Kitabayashi Date: Fri, 9 Oct 2026 00:12:04 +0200 Subject: [PATCH 1/2] fix(mission): receive prints a completion once receive queued the completion's acknowledgement and exited before Nirux recorded it, so a receive run right away printed the same completion again until the queue was drained. It now waits for that record, like reply waits for its answer. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/Nirux/Model/MissionEventCLI.swift | 14 +++++++++-- Tests/NiruxTests/MissionAskTests.swift | 29 ++++++++++++++++++++++- Tests/NiruxTests/MissionTellTests.swift | 3 ++- Tests/NiruxTests/MissionTests.swift | 6 +++-- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/Sources/Nirux/Model/MissionEventCLI.swift b/Sources/Nirux/Model/MissionEventCLI.swift index ef43865e..03d3ecc3 100644 --- a/Sources/Nirux/Model/MissionEventCLI.swift +++ b/Sources/Nirux/Model/MissionEventCLI.swift @@ -274,14 +274,17 @@ enum MissionEventCLI { /// Wait for the next unconsumed child question/completion belonging to /// this parent column. Questions stay pending until a response is sent; - /// completion is acknowledged immediately after it is printed. + /// completion is acknowledged once printed, and `receive` returns only + /// after Nirux recorded that (or `confirmationTimeout`), so running it + /// again right away does not print the same completion again. static func receive( arguments: [String], environment: [String: String] = ProcessInfo.processInfo.environment, now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }, eventsURL: URL = MissionEventCenter.defaultEventsURL, missionsURL: URL = MissionStore.defaultFileURL, - pollInterval: TimeInterval = 0.2 + pollInterval: TimeInterval = 0.2, + confirmationTimeout: TimeInterval = 5 ) -> Int32 { guard let context = parentContext(environment) else { return notAMissionTerminal(child: false) } guard let options = parseOptions(arguments, allowed: ["--timeout"]), @@ -340,6 +343,13 @@ enum MissionEventCLI { timestamp: now() ) guard append(acknowledgement, to: eventsURL) else { return 1 } + _ = waitForLedger( + &ledger, timeout: confirmationTimeout, pollInterval: pollInterval + ) { missions in + missions.lazy.flatMap(\.events).contains(where: { + $0.id == event.id && $0.parentConsumedAt != nil + }) ? true : nil + } } return 0 } diff --git a/Tests/NiruxTests/MissionAskTests.swift b/Tests/NiruxTests/MissionAskTests.swift index c2855a41..73aaf33c 100644 --- a/Tests/NiruxTests/MissionAskTests.swift +++ b/Tests/NiruxTests/MissionAskTests.swift @@ -271,7 +271,8 @@ final class MissionAskTests: XCTestCase { environment: parentEnvironment, eventsURL: fixture.eventsURL, missionsURL: fixture.missionsURL, - pollInterval: 0.01 + pollInterval: 0.01, + confirmationTimeout: 0 ) } XCTAssertEqual(receive(), 3, "the child is still working") @@ -301,6 +302,32 @@ final class MissionAskTests: XCTestCase { ), 1) } + func testReceiveDeliversACompletionOnceWhenRunAgainRightAway() async throws { + let fixture = try makeFixture() + complete(fixture) + let environment = parentEnvironment + let eventsURL = fixture.eventsURL + let missionsURL = fixture.missionsURL + let receive: @Sendable () -> Int32 = { + MissionEventCLI.receive( + arguments: ["--timeout", "0"], + environment: environment, + eventsURL: eventsURL, + missionsURL: missionsURL, + pollInterval: 0.01 + ) + } + let first = Task.detached { receive() } + // Nirux drains the queue a moment after the acknowledgement lands. + Task { @MainActor in + try await Task.sleep(nanoseconds: 300_000_000) + fixture.center.drain() + } + let status = await first.value + XCTAssertEqual(status, 0, "prints the completion") + XCTAssertEqual(receive(), 4, "the completion is not printed again") + } + func testReplyStopsWhenTheQuestionWasAlreadyAnswered() throws { let fixture = try makeFixture() XCTAssertEqual(ask("Which API?", in: fixture), 3) diff --git a/Tests/NiruxTests/MissionTellTests.swift b/Tests/NiruxTests/MissionTellTests.swift index d1387b01..65e41f5c 100644 --- a/Tests/NiruxTests/MissionTellTests.swift +++ b/Tests/NiruxTests/MissionTellTests.swift @@ -234,7 +234,8 @@ final class MissionTellTests: XCTestCase { environment: parentEnvironment(), eventsURL: fixture.eventsURL, missionsURL: fixture.missionsURL, - pollInterval: 0.01 + pollInterval: 0.01, + confirmationTimeout: 0 ) } complete(fixture) diff --git a/Tests/NiruxTests/MissionTests.swift b/Tests/NiruxTests/MissionTests.swift index bcb3f147..aeb7c9a6 100644 --- a/Tests/NiruxTests/MissionTests.swift +++ b/Tests/NiruxTests/MissionTests.swift @@ -208,7 +208,8 @@ final class MissionTests: XCTestCase { now: { 40 }, eventsURL: eventsURL, missionsURL: missionsURL, - pollInterval: 0.01 + pollInterval: 0.01, + confirmationTimeout: 0 ), 0) let acknowledgement = try decodeSingleEvent(from: eventsURL) XCTAssertEqual(acknowledgement.kind, .acknowledged) @@ -387,7 +388,8 @@ extension MissionTests { now: { 40 }, eventsURL: eventsURL, missionsURL: missionsURL, - pollInterval: 0.01 + pollInterval: 0.01, + confirmationTimeout: 0 ), 0) let center = MissionEventCenter(store: store, eventsURL: eventsURL, isEnabled: { true }) center.drain() From ff9e232cdb6bc850841848db9090d7fae139761e Mon Sep 17 00:00:00 2001 From: Cheun Kitabayashi Date: Fri, 9 Oct 2026 00:15:02 +0200 Subject: [PATCH 2/2] test(mission): run receive twice while Nirux drains, as a looping parent does The test drained once after a fixed delay, so a slow machine could miss the acknowledgement. It now drains until the completion is recorded, while the two receives run back to back. confirmationTimeout sits before pollInterval, as in reply. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/Nirux/Model/MissionEventCLI.swift | 10 +++++----- Tests/NiruxTests/MissionAskTests.swift | 19 +++++++++++-------- Tests/NiruxTests/MissionTellTests.swift | 4 ++-- Tests/NiruxTests/MissionTests.swift | 8 ++++---- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/Sources/Nirux/Model/MissionEventCLI.swift b/Sources/Nirux/Model/MissionEventCLI.swift index 03d3ecc3..c52af012 100644 --- a/Sources/Nirux/Model/MissionEventCLI.swift +++ b/Sources/Nirux/Model/MissionEventCLI.swift @@ -274,17 +274,17 @@ enum MissionEventCLI { /// Wait for the next unconsumed child question/completion belonging to /// this parent column. Questions stay pending until a response is sent; - /// completion is acknowledged once printed, and `receive` returns only - /// after Nirux recorded that (or `confirmationTimeout`), so running it - /// again right away does not print the same completion again. + /// completion is acknowledged after it is printed, and `receive` returns + /// only once Nirux recorded that (or after `confirmationTimeout`), so + /// running it again right away does not print the same completion again. static func receive( arguments: [String], environment: [String: String] = ProcessInfo.processInfo.environment, now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }, eventsURL: URL = MissionEventCenter.defaultEventsURL, missionsURL: URL = MissionStore.defaultFileURL, - pollInterval: TimeInterval = 0.2, - confirmationTimeout: TimeInterval = 5 + confirmationTimeout: TimeInterval = 5, + pollInterval: TimeInterval = 0.2 ) -> Int32 { guard let context = parentContext(environment) else { return notAMissionTerminal(child: false) } guard let options = parseOptions(arguments, allowed: ["--timeout"]), diff --git a/Tests/NiruxTests/MissionAskTests.swift b/Tests/NiruxTests/MissionAskTests.swift index 73aaf33c..1fb1d123 100644 --- a/Tests/NiruxTests/MissionAskTests.swift +++ b/Tests/NiruxTests/MissionAskTests.swift @@ -271,8 +271,8 @@ final class MissionAskTests: XCTestCase { environment: parentEnvironment, eventsURL: fixture.eventsURL, missionsURL: fixture.missionsURL, - pollInterval: 0.01, - confirmationTimeout: 0 + confirmationTimeout: 0, + pollInterval: 0.01 ) } XCTAssertEqual(receive(), 3, "the child is still working") @@ -317,15 +317,18 @@ final class MissionAskTests: XCTestCase { pollInterval: 0.01 ) } - let first = Task.detached { receive() } + // The parent runs `receive` again as soon as the first one returns. + let twice = Task.detached { (receive(), receive()) } // Nirux drains the queue a moment after the acknowledgement lands. - Task { @MainActor in - try await Task.sleep(nanoseconds: 300_000_000) + try await Task.sleep(nanoseconds: 200_000_000) + let deadline = Date().addingTimeInterval(5) + while fixture.store.missions[0].events[0].parentConsumedAt == nil, Date() < deadline { fixture.center.drain() + try await Task.sleep(nanoseconds: 10_000_000) } - let status = await first.value - XCTAssertEqual(status, 0, "prints the completion") - XCTAssertEqual(receive(), 4, "the completion is not printed again") + let (first, second) = await twice.value + XCTAssertEqual(first, 0, "prints the completion") + XCTAssertEqual(second, 4, "the completion is not printed again") } func testReplyStopsWhenTheQuestionWasAlreadyAnswered() throws { diff --git a/Tests/NiruxTests/MissionTellTests.swift b/Tests/NiruxTests/MissionTellTests.swift index 65e41f5c..94a23d18 100644 --- a/Tests/NiruxTests/MissionTellTests.swift +++ b/Tests/NiruxTests/MissionTellTests.swift @@ -234,8 +234,8 @@ final class MissionTellTests: XCTestCase { environment: parentEnvironment(), eventsURL: fixture.eventsURL, missionsURL: fixture.missionsURL, - pollInterval: 0.01, - confirmationTimeout: 0 + confirmationTimeout: 0, + pollInterval: 0.01 ) } complete(fixture) diff --git a/Tests/NiruxTests/MissionTests.swift b/Tests/NiruxTests/MissionTests.swift index aeb7c9a6..68bfa97a 100644 --- a/Tests/NiruxTests/MissionTests.swift +++ b/Tests/NiruxTests/MissionTests.swift @@ -208,8 +208,8 @@ final class MissionTests: XCTestCase { now: { 40 }, eventsURL: eventsURL, missionsURL: missionsURL, - pollInterval: 0.01, - confirmationTimeout: 0 + confirmationTimeout: 0, + pollInterval: 0.01 ), 0) let acknowledgement = try decodeSingleEvent(from: eventsURL) XCTAssertEqual(acknowledgement.kind, .acknowledged) @@ -388,8 +388,8 @@ extension MissionTests { now: { 40 }, eventsURL: eventsURL, missionsURL: missionsURL, - pollInterval: 0.01, - confirmationTimeout: 0 + confirmationTimeout: 0, + pollInterval: 0.01 ), 0) let center = MissionEventCenter(store: store, eventsURL: eventsURL, isEnabled: { true }) center.drain()