File size: 13,725 Bytes
083f30c
 
0dff311
01b2c65
133255a
 
 
083f30c
 
 
 
 
 
 
 
 
 
 
01b2c65
 
 
 
0dff311
083f30c
0dff311
 
083f30c
0dff311
083f30c
 
 
 
 
 
0dff311
 
 
083f30c
 
 
 
 
 
 
 
 
 
0dff311
 
 
083f30c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0dff311
 
083f30c
 
 
 
 
 
 
 
0dff311
 
 
083f30c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f40c56
468fec4
083f30c
468fec4
083f30c
 
 
 
468fec4
 
083f30c
 
 
 
 
 
468fec4
083f30c
 
 
468fec4
 
23f3e95
083f30c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d4d3033
 
 
083f30c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468fec4
083f30c
 
 
 
 
2f40c56
468fec4
d4d3033
083f30c
 
 
 
 
 
 
 
 
 
01b2c65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f40c56
01b2c65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
"""Manager-level task and cancellation ownership tests."""

import asyncio
import logging

import pytest

from free_claude_code.messaging.models import IncomingMessage, MessageScope
from free_claude_code.messaging.trees import (
    CancellationReason,
    CancellationUiOwner,
    FailureResult,
    MessageState,
    NodeClaim,
    QueueEntry,
    TreeQueueManager,
)
from free_claude_code.messaging.trees import manager as manager_module
from free_claude_code.messaging.trees import processor as processor_module
from free_claude_code.messaging.trees.node import MessageNode
from free_claude_code.messaging.trees.processor import TreeQueueProcessor
from free_claude_code.messaging.trees.runtime import MessageTree

_SCOPE = MessageScope(platform="telegram", chat_id="chat")


def _incoming(node_id: str, *, reply_to: str | None = None) -> IncomingMessage:
    return IncomingMessage(
        text=f"prompt {node_id}",
        chat_id=_SCOPE.chat_id,
        user_id="user",
        message_id=node_id,
        platform=_SCOPE.platform,
        reply_to_message_id=reply_to,
    )


async def _wait_for_no_tasks(manager: TreeQueueManager) -> None:
    """Yield deterministic ready-queue checkpoints until task cleanup completes."""
    loop = asyncio.get_running_loop()
    for _ in range(20):
        if manager.task_count() == 0:
            return
        checkpoint = asyncio.Event()
        loop.call_soon(checkpoint.set)
        await checkpoint.wait()
    assert manager.task_count() == 0


@pytest.mark.asyncio
async def test_active_cancel_returns_runner_owned_effect_and_terminal_snapshot() -> (
    None
):
    started = asyncio.Event()

    async def process(_claim: NodeClaim) -> None:
        started.set()
        await asyncio.Event().wait()

    manager = TreeQueueManager(process)
    await manager.admit(_incoming("root"), "status-root")
    await started.wait()

    result = await manager.cancel_node(
        _SCOPE,
        "root",
        reason=CancellationReason.STOP,
    )

    assert [(effect.node.node_id, effect.ui_owner) for effect in result.effects] == [
        ("root", CancellationUiOwner.RUNNER)
    ]
    assert len(result.snapshots) == 1
    assert result.snapshots[0].nodes["root"]["state"] == "error"
    view = await manager.get_node(_SCOPE, "root")
    assert view is not None and view.state is MessageState.ERROR
    assert manager.task_count() == 0


@pytest.mark.asyncio
async def test_queued_cancel_returns_workflow_effect_and_exact_queue_update() -> None:
    release_root = asyncio.Event()
    root_started = asyncio.Event()
    child_started = asyncio.Event()
    queue_updates: list[tuple[tuple[str, int], ...]] = []

    async def process(claim: NodeClaim) -> None:
        if claim.node.node_id == "root":
            root_started.set()
            await release_root.wait()
        else:
            child_started.set()

    async def capture_queue(queue: tuple[QueueEntry, ...]) -> None:
        queue_updates.append(
            tuple((entry.node.node_id, entry.position) for entry in queue)
        )

    manager = TreeQueueManager(process, queue_update_callback=capture_queue)
    await manager.admit(_incoming("root"), "status-root")
    await root_started.wait()
    decision = await manager.admit(
        _incoming("child", reply_to="root"),
        "status-child",
        parent_reference_id="root",
    )
    assert decision.position == 1

    result = await manager.cancel_node(
        _SCOPE,
        "child",
        reason=CancellationReason.STOP,
    )

    assert [(effect.node.node_id, effect.ui_owner) for effect in result.effects] == [
        ("child", CancellationUiOwner.WORKFLOW)
    ]
    assert queue_updates == [()]
    assert result.snapshots[0].nodes["child"]["state"] == "error"
    assert child_started.is_set() is False

    release_root.set()
    await _wait_for_no_tasks(manager)
    assert child_started.is_set() is False


@pytest.mark.asyncio
async def test_cancel_cleanup_timeout_is_bounded_and_task_remains_owned(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(manager_module, "CANCEL_TASK_DRAIN_TIMEOUT_S", 0.01)
    started = asyncio.Event()
    cancellation_seen = asyncio.Event()
    release_cleanup = asyncio.Event()

    async def process(_claim: NodeClaim) -> None:
        started.set()
        try:
            await asyncio.Event().wait()
        except asyncio.CancelledError:
            cancellation_seen.set()
            await release_cleanup.wait()
            raise

    manager = TreeQueueManager(process)
    await manager.admit(_incoming("root"), "status-root")
    await started.wait()

    try:
        result = await asyncio.wait_for(
            manager.cancel_node(
                _SCOPE,
                "root",
                reason=CancellationReason.STOP,
            ),
            timeout=0.5,
        )
        await cancellation_seen.wait()
        assert result.effects[0].node.node_id == "root"
        assert manager.task_count() == 1
    finally:
        release_cleanup.set()

    await _wait_for_no_tasks(manager)


@pytest.mark.asyncio
async def test_escaped_processor_failure_persists_effects_through_manager_owner() -> (
    None
):
    started = asyncio.Event()
    release = asyncio.Event()
    failures: list[FailureResult] = []
    queue_updates: list[tuple[QueueEntry, ...]] = []

    async def process(claim: NodeClaim) -> None:
        if claim.node.node_id == "root":
            started.set()
            await release.wait()
            raise RuntimeError("processor boundary failed")

    async def capture_queue(queue: tuple[QueueEntry, ...]) -> None:
        queue_updates.append(queue)

    manager = TreeQueueManager(
        process,
        queue_update_callback=capture_queue,
        unexpected_failure_callback=failures.append,
    )
    await manager.admit(_incoming("root"), "status-root")
    await started.wait()
    await manager.admit(
        _incoming("child", reply_to="root"),
        "status-child",
        parent_reference_id="root",
    )

    release.set()
    await _wait_for_no_tasks(manager)

    assert len(failures) == 1
    failure = failures[0]
    assert failure.snapshot is not None
    assert {target.node_id for target in failure.affected} == {"root", "child"}
    assert failure.snapshot.nodes["root"]["state"] == "error"
    assert failure.snapshot.nodes["child"]["state"] == "error"
    assert queue_updates == [()]


@pytest.mark.asyncio
async def test_wait_idle_spans_successor_publication_and_completion() -> None:
    root_started = asyncio.Event()
    release_root = asyncio.Event()
    child_started = asyncio.Event()
    release_child = asyncio.Event()

    async def process(claim: NodeClaim) -> None:
        if claim.node.node_id == "root":
            root_started.set()
            await release_root.wait()
            return
        child_started.set()
        await release_child.wait()

    manager = TreeQueueManager(process)
    await asyncio.wait_for(manager.wait_idle(), timeout=0.1)
    await manager.admit(_incoming("root"), "status-root")
    await asyncio.wait_for(root_started.wait(), timeout=1)
    await manager.admit(
        _incoming("child", reply_to="root"),
        "status-child",
        parent_reference_id="root",
    )
    idle_task = asyncio.create_task(manager.wait_idle())

    try:
        await asyncio.sleep(0)
        assert not idle_task.done()

        release_root.set()
        await asyncio.wait_for(child_started.wait(), timeout=1)
        assert not idle_task.done()

        release_child.set()
        await asyncio.wait_for(idle_task, timeout=1)
        assert manager.task_count() == 0
    finally:
        release_root.set()
        release_child.set()
        if not idle_task.done():
            idle_task.cancel()
            with pytest.raises(asyncio.CancelledError):
                await idle_task


@pytest.mark.asyncio
async def test_wait_idle_spans_pre_run_cancellation_recovery() -> None:
    finish_started = asyncio.Event()
    release_finish = asyncio.Event()

    async def process(_claim: NodeClaim) -> None:
        raise AssertionError("pre-run cancellation must not enter the processor")

    async def fail_claim(_claim: NodeClaim) -> None:
        raise AssertionError("cancellation must not fail the claim")

    async def finish_claim(_tree: MessageTree, _claim: NodeClaim) -> None:
        finish_started.set()
        await release_finish.wait()

    tree = MessageTree(
        MessageNode(
            node_id="root",
            scope=_SCOPE,
            prompt="prompt root",
            status_message_id="status-root",
        )
    )
    decision = await tree.enqueue_or_claim("root")
    assert decision.claim is not None
    processor = TreeQueueProcessor(
        process,
        claim_failure_callback=fail_claim,
        claim_finished_callback=finish_claim,
    )
    processor.launch(tree, decision.claim)
    cancelled = processor.cancel(decision.claim, CancellationReason.STOP)
    assert cancelled is not None
    assert cancelled.runner_started is False
    idle_task = asyncio.create_task(processor.wait_idle())

    try:
        await asyncio.wait_for(finish_started.wait(), timeout=1)
        assert not idle_task.done()

        release_finish.set()
        await asyncio.wait_for(cancelled.task, timeout=1)
        await asyncio.wait_for(idle_task, timeout=1)
        assert processor.task_count() == 0
    finally:
        release_finish.set()
        if not idle_task.done():
            idle_task.cancel()
            with pytest.raises(asyncio.CancelledError):
                await idle_task


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("log_messaging_error_details", "secret_is_logged"),
    [(False, False), (True, True)],
)
async def test_wait_idle_surfaces_finish_failure_without_leaking_task_owner(
    caplog: pytest.LogCaptureFixture,
    log_messaging_error_details: bool,
    secret_is_logged: bool,
) -> None:
    secret = "unique-finish-callback-secret"
    finish_error = RuntimeError(secret)
    finish_calls = 0

    async def process(_claim: NodeClaim) -> None:
        return

    async def fail_claim(_claim: NodeClaim) -> None:
        raise AssertionError("successful processing must not fail the claim")

    async def finish_claim(_tree: MessageTree, _claim: NodeClaim) -> None:
        nonlocal finish_calls
        finish_calls += 1
        raise finish_error

    tree = MessageTree(
        MessageNode(
            node_id="root",
            scope=_SCOPE,
            prompt="prompt root",
            status_message_id="status-root",
        )
    )
    decision = await tree.enqueue_or_claim("root")
    assert decision.claim is not None
    processor = TreeQueueProcessor(
        process,
        claim_failure_callback=fail_claim,
        claim_finished_callback=finish_claim,
        log_messaging_error_details=log_messaging_error_details,
    )

    with caplog.at_level(logging.ERROR):
        processor.launch(tree, decision.claim)
        with pytest.raises(RuntimeError) as raised:
            await asyncio.wait_for(processor.wait_idle(), timeout=1)

    assert raised.value is finish_error
    assert finish_calls == 1
    assert processor.task_count() == 0
    await asyncio.wait_for(processor.wait_idle(), timeout=0.1)

    messages = "\n".join(record.getMessage() for record in caplog.records)
    assert "Claim completion callback failed for node root" in messages
    assert "RuntimeError" in messages
    assert (secret in messages) is secret_is_logged


@pytest.mark.asyncio
async def test_failed_launch_rolls_idle_state_back(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    async def process(_claim: NodeClaim) -> None:
        return

    async def fail_claim(_claim: NodeClaim) -> None:
        return

    async def finish_claim(_tree: MessageTree, _claim: NodeClaim) -> None:
        return

    tree = MessageTree(
        MessageNode(
            node_id="root",
            scope=_SCOPE,
            prompt="prompt root",
            status_message_id="status-root",
        )
    )
    decision = await tree.enqueue_or_claim("root")
    assert decision.claim is not None
    processor = TreeQueueProcessor(
        process,
        claim_failure_callback=fail_claim,
        claim_finished_callback=finish_claim,
    )

    def fail_create_task(*_args: object, **_kwargs: object) -> None:
        raise RuntimeError("launch failed")

    monkeypatch.setattr(
        processor_module.asyncio,
        "create_task",
        fail_create_task,
    )

    with pytest.raises(RuntimeError, match="launch failed"):
        processor.launch(tree, decision.claim)

    assert processor.task_count() == 0
    await asyncio.wait_for(processor.wait_idle(), timeout=0.1)


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("log_messaging_error_details", "secret_is_logged"),
    [(False, False), (True, True)],
)
async def test_processor_failure_logging_respects_diagnostic_policy(
    caplog: pytest.LogCaptureFixture,
    log_messaging_error_details: bool,
    secret_is_logged: bool,
) -> None:
    secret = "unique-processor-exception-secret"

    async def process(_claim: NodeClaim) -> None:
        raise RuntimeError(secret)

    manager = TreeQueueManager(
        process,
        log_messaging_error_details=log_messaging_error_details,
    )
    with caplog.at_level(logging.ERROR):
        await manager.admit(_incoming("root"), "status-root")
        await asyncio.wait_for(manager.wait_idle(), timeout=1)

    messages = "\n".join(record.getMessage() for record in caplog.records)

    assert "Error processing node root" in messages
    assert "RuntimeError" in messages
    assert (secret in messages) is secret_is_logged