claude-multiacc 2.0.58 → 2.0.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -4
- package/bin/claude +389 -94
- package/bin/claude-accounts +276 -42
- package/bin/codex +69 -5
- package/bin/codex-accounts +33 -4
- package/docs/ACCOUNT_OPERATIONS.md +62 -2
- package/docs/AUTORESUME-HANDOFF.md +28 -3
- package/docs/AUTORESUME.md +275 -18
- package/docs/CODEX.md +1 -0
- package/docs/INSTALLATION.md +7 -1
- package/docs/VERIFICATION.md +4 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +88 -22
- package/lib/autoresume.py +1197 -82
- package/lib/autoresume_cross.py +3 -1
- package/lib/autoresume_handoff.py +81 -9
- package/lib/autoresume_probe.py +2 -2
- package/lib/claude_client_error.py +9 -4
- package/lib/codex_trust.py +263 -0
- package/lib/common.sh +146 -3
- package/lib/credential.py +5 -2
- package/lib/install_actions.sh +19 -0
- package/lib/keychain.py +180 -36
- package/lib/mcp_oauth.py +1748 -0
- package/lib/mcp_registry.py +230 -8
- package/lib/model_catalog.json +404 -0
- package/lib/model_catalog.py +201 -0
- package/lib/model_match.py +104 -0
- package/lib/report.py +8 -5
- package/lib/session_settings.py +123 -0
- package/package.json +2 -2
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/fake_codex_tui.py +25 -0
- package/tests/fake_security.sh +24 -2
- package/tests/fixtures/cross_provider_cli.py +109 -0
- package/tests/run-tests.sh +1171 -23
- package/tests/test_autoresume.py +215 -8
- package/tests/test_autoresume_catalog.py +226 -0
- package/tests/test_autoresume_catalog_cli.py +185 -0
- package/tests/test_autoresume_codex_records.py +16 -0
- package/tests/test_autoresume_codex_trust.py +247 -0
- package/tests/test_autoresume_cross_guards.py +28 -0
- package/tests/test_autoresume_cross_tmux.py +27 -2
- package/tests/test_autoresume_cross_tokens.py +32 -2
- package/tests/test_autoresume_handoff_argv.py +120 -16
- package/tests/test_autoresume_handoff_origins.py +14 -0
- package/tests/test_autoresume_memory.py +1141 -0
- package/tests/test_autoresume_memory_tmux.py +94 -0
- package/tests/test_autoresume_probe.py +2 -1
- package/tests/test_autoresume_relaunch_settings.py +317 -0
- package/tests/test_autoresume_settings.py +135 -0
- package/tests/test_autoresume_sidechain.py +1518 -0
- package/tests/test_autoresume_sidechain_tmux.py +79 -0
- package/tests/test_autoresume_trust_tmux.py +63 -0
- package/tests/test_claude_client_error.py +10 -7
- package/tests/test_claude_org_block.py +1 -0
- package/tests/test_claude_pool_prepare.py +4 -1
- package/tests/test_claude_reset.py +3 -0
- package/tests/test_keychain_nologin.py +340 -0
- package/tests/test_mcp_oauth.py +1414 -0
- package/tests/test_mcp_registry.py +234 -3
- package/tests/test_packaged_commands.py +41 -0
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A claude client at critical memory is stopped and resumed like one at a usage limit.
|
|
3
|
+
|
|
4
|
+
claude 2.1.304 reads its heap every 10 s and, from 2.5 GiB on, shows "Critical memory usage
|
|
5
|
+
(8.3GB) — restart and resume with claude --continue" in its prompt footer; nothing of it
|
|
6
|
+
reaches the transcript. On 2026-10-20 a session running eight background workflows reached
|
|
7
|
+
8.3GB, and its client then ignored SIGTERM for more than 15 s. The watcher reads its own pane
|
|
8
|
+
for the banner (capture-pane, read-only) and moves the session through the usual path.
|
|
9
|
+
Disposable pools, the fake runner and its fake clock only: no real signal, process or tmux."""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import signal
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import unittest
|
|
17
|
+
from unittest import mock
|
|
18
|
+
|
|
19
|
+
import test_autoresume as fixtures
|
|
20
|
+
from test_autoresume import (CX_SID, SID, ar, cc_api_error, cc_quota, cc_reply, cc_user, cfg,
|
|
21
|
+
cx_meta, jl)
|
|
22
|
+
from test_autoresume_sidechain import TEXT, api_request, block, result_of, tool_use, write
|
|
23
|
+
|
|
24
|
+
SOCK = "/private/tmp/tmux-501/default"
|
|
25
|
+
RULE = "─" * 200
|
|
26
|
+
BANNER = ("Critical memory usage (8.3GB) — restart and resume with claude --continue, "
|
|
27
|
+
"or run /compact")
|
|
28
|
+
MODE = " ⏵⏵ bypass permissions on · 3 shells"
|
|
29
|
+
CONVERSATION = ["⏺ Started the release workflows; I will report when they finish.", ""]
|
|
30
|
+
WAITING = "✻ Waiting for 3 dynamic workflows to finish"
|
|
31
|
+
COMPACTING = "✻ Compacting conversation… (1m 12s · esc to interrupt · ctrl+t to show todos)"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def screen(banner=BANNER, above=CONVERSATION, footer=MODE, tasks=3, width=200, spinner=WAITING,
|
|
35
|
+
prompt=("❯\xa0",), task_names=None):
|
|
36
|
+
"""A claude 2.1.304 pane as `capture-pane -p -J` prints it: the conversation, the spinner,
|
|
37
|
+
the prompt between its two rules (its row the mode indicator and a no-break space), the
|
|
38
|
+
footer row (the notification column right-aligned after the mode line), a row per
|
|
39
|
+
background task, then blank rows down to the bottom."""
|
|
40
|
+
rows = list(above) + [spinner, "", RULE[:width - 12] + " ultracode ─"] + list(prompt) \
|
|
41
|
+
+ [RULE[:width]]
|
|
42
|
+
if banner:
|
|
43
|
+
rows.append(footer + " " * max(2, width - len(footer) - len(banner) - 2) + banner)
|
|
44
|
+
else:
|
|
45
|
+
rows.append(footer)
|
|
46
|
+
rows.append("")
|
|
47
|
+
names = task_names or ["release-step-%d" % i for i in range(tasks)]
|
|
48
|
+
rows += [" ◯ %s ▰▱▱▱▱▱▱▱▱▱▱▱ 1/12 · 10m42s · ↓ 1.4m tokens" % name
|
|
49
|
+
for name in names]
|
|
50
|
+
return "\n".join(rows + [""] * 8) + "\n"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
QUOTED = ["⏺ The pane said: " + BANNER,
|
|
54
|
+
" ⎿ 2026-10-20T08:01:44Z Critical memory usage (8.3GB) — restart and resume with "
|
|
55
|
+
"claude --continue",
|
|
56
|
+
"Critical memory usage (9GB) — restart and resume with claude --continue", ""]
|
|
57
|
+
QUOTE = "Critical memory usage (8.3GB) — restart and resume with claude --continue"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def permission_dialog(quoted, width=120, title="Bash command"):
|
|
61
|
+
"""claude 2.1.304's permission dialog, drawn in the prompt's place (its frame, Da, draws a
|
|
62
|
+
top border alone: one rule across the pane) with what the tool would run quoted in its
|
|
63
|
+
body; neither the prompt's box nor its footer is drawn while it is up."""
|
|
64
|
+
return "\n".join(CONVERSATION + [
|
|
65
|
+
"⏺ Bash(" + quoted[:60] + ")", "", "─" * width, " " + title, "", " " + quoted,
|
|
66
|
+
" Print the banner the watcher must know", "", " Do you want to proceed?",
|
|
67
|
+
" ❯ 1. Yes", " 2. Yes, and don't ask again for echo commands in /work",
|
|
68
|
+
" 3. No, and tell Claude what to do differently (esc)"] + [""] * 6) + "\n"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def plan_dialog(plan, width=120):
|
|
72
|
+
"""The plan's approval (2.1.304): "Ready to code?" in the same top-border frame, the plan,
|
|
73
|
+
then its options in a second such frame -- two rules, the plan and the options under them."""
|
|
74
|
+
return "\n".join(CONVERSATION + [
|
|
75
|
+
"", "─" * width, " Ready to code?", "", " Here is Claude's plan:", ""] + list(plan) + [
|
|
76
|
+
"", "─" * width,
|
|
77
|
+
" Claude has written up a plan and is ready to execute. Would you like to proceed?", "",
|
|
78
|
+
" ❯ 1. Yes, and use auto mode", " 2. Yes, manually approve edits",
|
|
79
|
+
" 3. No, keep planning", "", " ctrl+g to edit in vim · ~/.claude/plans/memory.md"]
|
|
80
|
+
+ [""] * 4) + "\n"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class MemoryBannerTests(unittest.TestCase):
|
|
84
|
+
"""memory_banner: the client's own words, where the client shows them, and nowhere else."""
|
|
85
|
+
|
|
86
|
+
def test_the_banner_in_the_footer_names_the_size(self):
|
|
87
|
+
self.assertEqual(ar.memory_banner(screen()), "8.3GB")
|
|
88
|
+
# without ", or run /compact" (a client that cannot compact here), and in colour
|
|
89
|
+
short = BANNER.split(",")[0]
|
|
90
|
+
self.assertEqual(ar.memory_banner(screen(short)), "8.3GB")
|
|
91
|
+
red = "\x1b[38;2;255;107;128m" + short + "\x1b[39m"
|
|
92
|
+
self.assertEqual(ar.memory_banner(screen(red).replace("❯\xa0", "\x1b[39m❯\xa0")),
|
|
93
|
+
"8.3GB")
|
|
94
|
+
for size in ("2.5GB", "12GB", "10.1GB", "900MB"):
|
|
95
|
+
self.assertEqual(ar.memory_banner(screen(BANNER.replace("8.3GB", size))), size)
|
|
96
|
+
|
|
97
|
+
def test_a_narrow_pane_truncates_it_and_a_column_footer_stacks_it(self):
|
|
98
|
+
for cut, want in (("Critical memory usage (8.3GB) — restart and r…", "8.3GB"),
|
|
99
|
+
("Critical memory usage (8.3GB) …", "8.3GB"),
|
|
100
|
+
("Critical memory usage (8.3GB)…", "8.3GB"),
|
|
101
|
+
("Critical memory usage (8.3G…", None),
|
|
102
|
+
("Critical memory usa…", None)):
|
|
103
|
+
self.assertEqual(ar.memory_banner(screen(cut, width=80)), want, cut)
|
|
104
|
+
stacked = screen(None, footer=MODE + "\n" + " " * 30 + BANNER[:60] + "…")
|
|
105
|
+
self.assertEqual(ar.memory_banner(stacked), "8.3GB")
|
|
106
|
+
# a row too narrow for both: the notification column abuts the mode line, cut short
|
|
107
|
+
for left in (" ⏵⏵ bypass permissions on · 3 she…", " ⏵⏵ bypass permissions on"):
|
|
108
|
+
abutting = screen(None, footer=left + BANNER[:70] + "…", width=110)
|
|
109
|
+
self.assertEqual(ar.memory_banner(abutting), "8.3GB", left)
|
|
110
|
+
# the footer's every row counts, down to its first blank one: the column wrapped under
|
|
111
|
+
# the mode line, or under other notifications, as a real 2.1.304 footer lays them out
|
|
112
|
+
# at 70 and 80 columns
|
|
113
|
+
wrapped = screen(None, width=70, footer=" ⏵⏵ auto mode on (shift+tab to cycle)\n"
|
|
114
|
+
+ " " * 4 + BANNER[:63] + "…")
|
|
115
|
+
self.assertEqual(ar.memory_banner(wrapped), "8.3GB")
|
|
116
|
+
crowded = screen(None, width=80, footer="\n".join([
|
|
117
|
+
" ⏵⏵ auto mode on (shift+tab to cycle)", " " * 52 + "Not logged in · Run /login",
|
|
118
|
+
" tmux focus-events off · add 'set -g focus-events on' to ~/.tmux.conf and re…",
|
|
119
|
+
" " * 4 + BANNER[:73] + "…"]))
|
|
120
|
+
self.assertEqual(ar.memory_banner(crowded), "8.3GB")
|
|
121
|
+
# a fullscreen client ("tui": "fullscreen"; the pool's accounts run "default") draws its
|
|
122
|
+
# notifications over the row above the box instead, among the conversation's rows,
|
|
123
|
+
# where the banner's words cannot be told from a reply's: that row is never read
|
|
124
|
+
overlaid = screen(None).replace("\n\n" + RULE[:188], "\n" + " " * 110 + BANNER[:86]
|
|
125
|
+
+ "\n" + RULE[:188], 1)
|
|
126
|
+
self.assertIn(BANNER[:86], overlaid)
|
|
127
|
+
self.assertIsNone(ar.memory_banner(overlaid))
|
|
128
|
+
|
|
129
|
+
def test_the_words_in_the_conversation_never_count(self):
|
|
130
|
+
# a reply quoting it, a tool's log line, a pasted line: all above the prompt's box
|
|
131
|
+
self.assertIsNone(ar.memory_banner(screen(None, above=QUOTED)))
|
|
132
|
+
# ...however many rows the footer and the tasks below it take
|
|
133
|
+
self.assertIsNone(ar.memory_banner(screen(None, above=QUOTED, tasks=0)))
|
|
134
|
+
self.assertIsNone(ar.memory_banner(screen(None, above=QUOTED, tasks=20)))
|
|
135
|
+
# and with the real banner in the footer it is the footer's size that counts
|
|
136
|
+
self.assertEqual(ar.memory_banner(screen(BANNER.replace("8.3GB", "3GB"),
|
|
137
|
+
above=QUOTED)), "3GB")
|
|
138
|
+
|
|
139
|
+
def test_without_the_prompts_box_there_is_no_banner(self):
|
|
140
|
+
# a screen with no rule at all (the transcript view, a full-screen picker)
|
|
141
|
+
self.assertIsNone(ar.memory_banner("\n".join(QUOTED + ["", " esc to go back"])))
|
|
142
|
+
# a dialog in the prompt's place: its words are inside its box, above the border
|
|
143
|
+
dialog = ["╭" + "─" * 98 + "╮", "│ " + BANNER + " │", "│ Do you want to proceed? │",
|
|
144
|
+
"╰" + "─" * 98 + "╯", " Esc to cancel"]
|
|
145
|
+
self.assertIsNone(ar.memory_banner("\n".join(CONVERSATION + dialog) + "\n\n"))
|
|
146
|
+
# what is below the footer's first blank row is not the footer
|
|
147
|
+
self.assertIsNone(ar.memory_banner(screen(None, tasks=0) + "\n".join(
|
|
148
|
+
[BANNER] + [" ◯ task-%d" % i for i in range(40)])))
|
|
149
|
+
self.assertIsNone(ar.memory_banner(""))
|
|
150
|
+
self.assertIsNone(ar.memory_banner(None))
|
|
151
|
+
|
|
152
|
+
def test_a_dialog_in_the_boxs_place_quotes_nothing(self):
|
|
153
|
+
# 2.1.304 frames its permission and plan dialogs with a top border alone, one rule
|
|
154
|
+
# across the pane, and what the tool would run (a command, an Edit's diff, the plan) is
|
|
155
|
+
# under it. That rule opens no prompt box, and the banner's words there never count
|
|
156
|
+
for quoted in ("echo '%s'" % QUOTE, "+ " + BANNER, QUOTE,
|
|
157
|
+
"grep -n 'Critical memory usage (8.3GB) — restart' docs/AUTORESUME.md"):
|
|
158
|
+
self.assertIsNone(ar.memory_banner(permission_dialog(quoted)), quoted)
|
|
159
|
+
self.assertIsNone(ar.memory_banner(permission_dialog(quoted, title=quoted)), quoted)
|
|
160
|
+
self.assertIsNone(ar.memory_banner(plan_dialog(
|
|
161
|
+
["1. Watch the pane for", " " + QUOTE, "2. Restart and resume the session"])))
|
|
162
|
+
self.assertIsNone(ar.memory_banner(plan_dialog([QUOTE, BANNER])))
|
|
163
|
+
# a picker (the session list) quoting an earlier prompt that pasted it, and a question
|
|
164
|
+
picker = ["", " Resume Session", "", " ❯ " + QUOTE + "… 2m ago",
|
|
165
|
+
" fix the release workflows 1h ago", "",
|
|
166
|
+
" ↑/↓ to select · Enter to resume · Esc to cancel"]
|
|
167
|
+
self.assertIsNone(ar.memory_banner("\n".join(CONVERSATION + picker) + "\n"))
|
|
168
|
+
question = ["", "─" * 120, " ☐ Restart", "", "The pane shows: " + QUOTE, "",
|
|
169
|
+
" ❯ 1. Restart now", " 2. Run /compact first", " 3. Type something."]
|
|
170
|
+
self.assertIsNone(ar.memory_banner("\n".join(CONVERSATION + question) + "\n"))
|
|
171
|
+
|
|
172
|
+
def test_only_the_prompts_own_row_opens_the_box(self):
|
|
173
|
+
# the prompt's row is the client's: its mode indicator in the pane's first column, then
|
|
174
|
+
# a no-break space. A copy of the box -- a capture pasted into the external editor, a
|
|
175
|
+
# picker's row under a rule -- has a plain space there
|
|
176
|
+
for prompt in ("❯\xa0", "❯\xa0what now?"):
|
|
177
|
+
pasted = screen(prompt=[prompt]).replace("❯\xa0", "❯ ")
|
|
178
|
+
self.assertIsNone(ar.memory_banner(pasted), prompt)
|
|
179
|
+
vim = pasted.rstrip("\n") + "\n" + "\n".join(
|
|
180
|
+
["~"] * 6 + ['"/tmp/claude-prompt-7f3a.md" 14L, 1320B'])
|
|
181
|
+
self.assertIsNone(ar.memory_banner(vim), prompt)
|
|
182
|
+
picker = ["─" * 120, "❯ fix the release workflows", "─" * 120, " " + QUOTE]
|
|
183
|
+
self.assertIsNone(ar.memory_banner("\n".join(CONVERSATION + picker) + "\n"))
|
|
184
|
+
# ...right under a rule from the pane's first column: an indented rule (a reply's
|
|
185
|
+
# markdown) opens nothing, and neither does a prompt row with no rule above it
|
|
186
|
+
self.assertIsNone(ar.memory_banner(screen().replace(RULE[:188] + " ultracode ─",
|
|
187
|
+
" " + RULE[:186])))
|
|
188
|
+
self.assertIsNone(ar.memory_banner(screen().replace(RULE[:188] + " ultracode ─\n", "")))
|
|
189
|
+
# ...and closed by one: no rule under it, no box
|
|
190
|
+
self.assertIsNone(ar.memory_banner(screen().replace("\xa0\n" + RULE[:200] + "\n",
|
|
191
|
+
"\xa0\n")))
|
|
192
|
+
|
|
193
|
+
def test_every_mode_indicator_and_a_draft_of_several_rows(self):
|
|
194
|
+
for prompt in (["!\xa0"], ["@reviewer\xa0"], [">\xa0ls -la"],
|
|
195
|
+
["❯\xa0first line", " second line"],
|
|
196
|
+
["❯\xa0" + "x" * 150] + [" " + "y" * 150] * 20):
|
|
197
|
+
self.assertEqual(ar.memory_banner(screen(prompt=prompt)), "8.3GB", prompt[0])
|
|
198
|
+
# the draft is inside the box: the banner's words typed or pasted there never count,
|
|
199
|
+
# not even a pasted capture with a rule and a footer of its own (the box indents them)
|
|
200
|
+
draft = ["❯\xa0what does this mean?", " " + RULE[:60], " " + MODE + " " + BANNER]
|
|
201
|
+
self.assertIsNone(ar.memory_banner(screen(None, prompt=draft)))
|
|
202
|
+
# a box holding a draft taller than PROMPT_ROWS is not read
|
|
203
|
+
tall = ["❯\xa0x"] + [" more"] * ar.PROMPT_ROWS
|
|
204
|
+
self.assertIsNone(ar.memory_banner(screen(prompt=tall)))
|
|
205
|
+
|
|
206
|
+
def test_the_background_tasks_below_the_footer_never_count(self):
|
|
207
|
+
# the task list starts after the footer's first blank row: a workflow named with the
|
|
208
|
+
# banner's words, whole or cut at its ellipsis, is not the client's status
|
|
209
|
+
for name in (QUOTE, QUOTE[:40] + "…", "Critical memory usage (8.3GB) - repro"):
|
|
210
|
+
self.assertIsNone(ar.memory_banner(screen(None, task_names=[name, "release"])), name)
|
|
211
|
+
self.assertEqual(ar.memory_banner(screen(task_names=[name])), "8.3GB", name)
|
|
212
|
+
|
|
213
|
+
def test_only_the_banners_own_shape(self):
|
|
214
|
+
task = " ◯ Critical memory usage (8.3GB) leak hunt ▱▱▱▱▱▱▱▱ 0/1 · 2m"
|
|
215
|
+
for row in (task, " Critical memory usage (8.3GB) restart and resume",
|
|
216
|
+
" Critical memory usage (lots) — restart",
|
|
217
|
+
" critical memory usage (8.3GB) —",
|
|
218
|
+
" Critical memory usage 8.3GB — restart",
|
|
219
|
+
" Critical memory usage (8.3 GB) —",
|
|
220
|
+
# the client's dash and words, whole or cut at an ellipsis, and nothing else
|
|
221
|
+
" Critical memory usage (8.3GB) - restart and resume with claude --continue",
|
|
222
|
+
" Critical memory usage (8.3GB) – restart and resume with claude --continue",
|
|
223
|
+
" Critical memory usage (8.3GB)",
|
|
224
|
+
" Critical memory usage (8.3GB) — restart and resume later",
|
|
225
|
+
" Critical memory usage (8.3GB) — restart and resume later…",
|
|
226
|
+
" Critical memory usage (8.3GB) - repro…"):
|
|
227
|
+
self.assertIsNone(ar.memory_banner(screen(None, footer=MODE + "\n" + row)), row)
|
|
228
|
+
# without ", or run /compact" (a client that cannot compact there) it is the banner
|
|
229
|
+
self.assertEqual(ar.memory_banner(screen(None, footer=MODE + " " + QUOTE)), "8.3GB")
|
|
230
|
+
|
|
231
|
+
def test_after_the_size_only_the_banners_own_dash_or_its_cut(self):
|
|
232
|
+
# 2.1.304 draws an em dash after the size, or cuts its text at an ellipsis (Ink's
|
|
233
|
+
# truncation, anywhere from right after the size on): a hyphen or an en dash there,
|
|
234
|
+
# whole or cut, is never the client's
|
|
235
|
+
for dash in ("-", "–"):
|
|
236
|
+
for row in (QUOTE.replace("—", dash), BANNER.replace("—", dash),
|
|
237
|
+
BANNER.replace("—", dash)[:50] + "…",
|
|
238
|
+
"Critical memory usage (8.3GB) %s…" % dash):
|
|
239
|
+
self.assertIsNone(ar.memory_banner(screen(row)), row)
|
|
240
|
+
for row in (QUOTE, BANNER[:50] + "…", "Critical memory usage (8.3GB) —…",
|
|
241
|
+
"Critical memory usage (8.3GB) …", "Critical memory usage (8.3GB)…"):
|
|
242
|
+
self.assertEqual(ar.memory_banner(screen(row)), "8.3GB", row)
|
|
243
|
+
|
|
244
|
+
def test_it_ends_its_row_but_for_the_clients_own_pills(self):
|
|
245
|
+
# The banner is the notification column's last line: whole or cut, nothing follows it
|
|
246
|
+
# on its row but the pills 2.1.304 draws right of that column, a stashed prompt's and
|
|
247
|
+
# an active goal's. A task's row, a log line or a reply that quotes it goes on
|
|
248
|
+
for row in (QUOTE + " (see the docs)", BANNER + " now", QUOTE + ", or run /compact please",
|
|
249
|
+
"Critical memory usage (8.3GB)… leak hunt",
|
|
250
|
+
BANNER[:50] + "… ▰▱▱▱▱▱▱▱ 1/12 · 10m42s",
|
|
251
|
+
QUOTE + " 10m42s · ↓ 1.4k tokens"):
|
|
252
|
+
self.assertIsNone(ar.memory_banner(screen(row)), row)
|
|
253
|
+
for pills in (" · › stashed", " › stashed", "◎ /goal active", " · ◎ /goal active (5m)",
|
|
254
|
+
" › stashed · ◎ /goal active (1h)"):
|
|
255
|
+
for row in (BANNER, QUOTE, BANNER[:50] + "…", "Critical memory usage (8.3GB)…"):
|
|
256
|
+
self.assertEqual(ar.memory_banner(screen(row + pills)), "8.3GB", row + pills)
|
|
257
|
+
self.assertIsNone(ar.memory_banner(screen(BANNER + pills + " later")), pills)
|
|
258
|
+
|
|
259
|
+
def test_a_tasks_row_never_counts_even_right_under_the_footer(self):
|
|
260
|
+
# A task's name and description are the model's words. Its row -- the pointer or two
|
|
261
|
+
# spaces, an agent's tree connector, the task's bullet -- is never read, even with no
|
|
262
|
+
# blank row between the footer and the task list
|
|
263
|
+
for row in (" ⏸ Critical memory usage (3GB)…", # a paused workflow, its name cut
|
|
264
|
+
" ◯ " + QUOTE, "❯ ◯ " + BANNER, "> ◯ " + QUOTE, # one selected
|
|
265
|
+
" ⏺ " + QUOTE, " ● " + QUOTE[:40] + "…", # the one viewed
|
|
266
|
+
" └ ◯ " + QUOTE, " ├ ◯ " + QUOTE, # an agent's agents
|
|
267
|
+
" ( ) " + QUOTE): # a terminal without the unicode bullets
|
|
268
|
+
self.assertIsNone(ar.memory_banner(screen(None, footer=MODE + "\n" + row)), row)
|
|
269
|
+
self.assertIsNone(ar.memory_banner(screen(None, footer=MODE + "\n" + MODE + "\n"
|
|
270
|
+
+ row)), row)
|
|
271
|
+
# ...while the client's own banner in the footer above it counts
|
|
272
|
+
self.assertEqual(ar.memory_banner(screen(None, footer=MODE + " " + BANNER
|
|
273
|
+
+ "\n" + row)), "8.3GB", row)
|
|
274
|
+
# The footer's first row is the client's own, never a task's (the list comes after the
|
|
275
|
+
# footer): a status line drawn with a bullet first still has the banner beside it
|
|
276
|
+
for status in (" ● main · ~/release", " ◯ idle · opus"):
|
|
277
|
+
self.assertEqual(ar.memory_banner(screen(None, footer=status + " " + BANNER
|
|
278
|
+
+ "\n" + MODE)), "8.3GB", status)
|
|
279
|
+
|
|
280
|
+
def test_a_compaction_under_way_shows_in_the_spinner_above_the_box(self):
|
|
281
|
+
self.assertTrue(ar.compacting(screen(spinner=COMPACTING)))
|
|
282
|
+
self.assertTrue(ar.compacting(screen(None, spinner=COMPACTING)))
|
|
283
|
+
# the todo list between the spinner and the box
|
|
284
|
+
todos = [COMPACTING, " ⎿ ☐ Build the release", " ☐ Write the notes",
|
|
285
|
+
" ☒ Tag it"]
|
|
286
|
+
self.assertTrue(ar.compacting(screen(above=CONVERSATION + todos[:-1],
|
|
287
|
+
spinner=todos[-1])))
|
|
288
|
+
self.assertFalse(ar.compacting(screen()))
|
|
289
|
+
self.assertFalse(ar.compacting(screen(spinner="✻ Compacted. ctrl+o to see the summary")))
|
|
290
|
+
# below the box it is a task's name, not the client's spinner; and no box, no spinner
|
|
291
|
+
self.assertFalse(ar.compacting(screen(footer=MODE + "\n ◯ " + COMPACTING[2:])))
|
|
292
|
+
self.assertFalse(ar.compacting("\n".join([COMPACTING, "", RULE, " esc to cancel"])))
|
|
293
|
+
self.assertFalse(ar.compacting(""))
|
|
294
|
+
|
|
295
|
+
def test_sizes_the_way_the_client_prints_them(self):
|
|
296
|
+
gib, mib = 1 << 30, 1 << 20
|
|
297
|
+
for nbytes, want in ((int(8.3 * gib), "8.3GB"), (10 * gib, "10GB"),
|
|
298
|
+
(int(2.5 * gib), "2.5GB"), (512 * mib, "512MB"), (3 * 1024, "3KB")):
|
|
299
|
+
self.assertEqual(ar.format_size(nbytes), want)
|
|
300
|
+
|
|
301
|
+
def test_capture_is_a_read(self):
|
|
302
|
+
self.assertEqual(ar.capture_argv(SOCK, "%7"),
|
|
303
|
+
["tmux", "-S", SOCK, "capture-pane", "-p", "-J", "-t", "%7"])
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class MemoryRulesTests(unittest.TestCase):
|
|
307
|
+
T = 1792440000
|
|
308
|
+
|
|
309
|
+
def test_knobs(self):
|
|
310
|
+
c = ar.Config("claude", {})
|
|
311
|
+
self.assertEqual((c.memory_scan, c.memory_min_uptime, c.memory_busy_cap,
|
|
312
|
+
c.memory_per_hour, c.memory_backoff, c.memory_limit_mb),
|
|
313
|
+
(30.0, 900.0, 600.0, 2, 600.0, 0))
|
|
314
|
+
c = cfg(MEMORY_SCAN=0, MEMORY_MIN_UPTIME=60, MEMORY_BUSY_CAP=30, MEMORY_PER_HOUR=5,
|
|
315
|
+
MEMORY_BACKOFF=90, MEMORY_LIMIT_MB=12000)
|
|
316
|
+
self.assertEqual((c.memory_scan, c.memory_min_uptime, c.memory_busy_cap,
|
|
317
|
+
c.memory_per_hour, c.memory_backoff, c.memory_limit_mb),
|
|
318
|
+
(0.0, 60.0, 30.0, 5, 90.0, 12000))
|
|
319
|
+
self.assertEqual(cfg(MEMORY_PER_HOUR="lots").memory_per_hour, 2)
|
|
320
|
+
|
|
321
|
+
def test_per_hour_budget(self):
|
|
322
|
+
c, t = ar.Config("claude", {}), self.T
|
|
323
|
+
self.assertEqual(ar.budget_check("memory", 0, "", t, c), (True, ""))
|
|
324
|
+
one = "memory:%d" % (t - 3000)
|
|
325
|
+
self.assertEqual(ar.budget_check("memory", 0, one, t, c), (True, ""))
|
|
326
|
+
two = "%s,quota:%d,memory:%d" % (one, t - 2000, t - 100)
|
|
327
|
+
self.assertEqual(ar.budget_check("memory", 0, two, t, c), (False, "memory-budget"))
|
|
328
|
+
# an hour on, the oldest no longer counts; other classes never count against it
|
|
329
|
+
self.assertEqual(ar.budget_check("memory", 0, two, t + 700, c), (True, ""))
|
|
330
|
+
self.assertEqual(ar.budget_check("memory", 0, "crash:%d,transient:%d" % (t, t), t, c),
|
|
331
|
+
(True, ""))
|
|
332
|
+
self.assertEqual(ar.budget_check("memory", 20, "", t, c), (False, "depth"))
|
|
333
|
+
# ...and memory relaunches never spend the others' budgets
|
|
334
|
+
self.assertEqual(ar.budget_check("crash", 0, two, t, c), (True, ""))
|
|
335
|
+
|
|
336
|
+
def test_the_prompt_says_why_and_never_claims_another_account(self):
|
|
337
|
+
text = ar.resume_prompt("memory", detail="8.3GB")
|
|
338
|
+
self.assertTrue(text.startswith(ar.PROMPT_PREFIX + " This session was restarted "
|
|
339
|
+
"automatically because the previous process reached "
|
|
340
|
+
"critical memory usage (8.3GB). Continue the task"))
|
|
341
|
+
self.assertIn("critical memory usage.", ar.resume_prompt("memory"))
|
|
342
|
+
self.assertEqual(ar.resume_prompt("memory", "Go on: {reason}, please", "9GB"),
|
|
343
|
+
"Go on: the previous process reached critical memory usage (9GB), please")
|
|
344
|
+
self.assertEqual(ar.relaunch_argv("claude", ["--dangerously-skip-permissions"], SID,
|
|
345
|
+
"memory", detail="8.3GB"),
|
|
346
|
+
["--dangerously-skip-permissions", "--resume", SID, text])
|
|
347
|
+
# a relaunch the probe accepts on any account with room, the one it leaves included
|
|
348
|
+
self.assertTrue(ar.probe_allows("memory", "acct-01", "soft", "acct-01"))
|
|
349
|
+
self.assertFalse(ar.probe_allows("memory", "acct-01", "hard", "acct-01"))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class MemoryRunner(fixtures.FakeRunner):
|
|
353
|
+
"""The fake runner with a pane to read: ``screen`` is what capture-pane prints (a hook can
|
|
354
|
+
change it), ``used`` the client's footprint in bytes (None: unreadable)."""
|
|
355
|
+
|
|
356
|
+
def __init__(self, *args):
|
|
357
|
+
super().__init__(*args)
|
|
358
|
+
self.screen = screen()
|
|
359
|
+
self.used = None
|
|
360
|
+
self.footprints = 0
|
|
361
|
+
|
|
362
|
+
def run(self, argv, timeout, env=None, cwd=None):
|
|
363
|
+
if argv[0] == "tmux" and "capture-pane" in argv:
|
|
364
|
+
self.calls.append({"argv": list(argv), "env": env, "cwd": cwd, "t": self.t})
|
|
365
|
+
return 0, self.screen
|
|
366
|
+
return super().run(argv, timeout, env, cwd)
|
|
367
|
+
|
|
368
|
+
def footprint(self, pid):
|
|
369
|
+
self.footprints += 1
|
|
370
|
+
return self.used if pid in self.live else None
|
|
371
|
+
|
|
372
|
+
@property
|
|
373
|
+
def captures(self):
|
|
374
|
+
return [c for c in self.calls if c["argv"][0] == "tmux" and "capture-pane" in c["argv"]]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class MemoryCase(fixtures.PoolCase):
|
|
378
|
+
def setUp(self):
|
|
379
|
+
super().setUp()
|
|
380
|
+
self.launched = int(self.t0) - 1000 # past MEMORY_MIN_UPTIME: the first scan is now
|
|
381
|
+
|
|
382
|
+
def transcript(self, records, sid=SID):
|
|
383
|
+
"""Main transcript lines as the client writes them (compact JSON: main_thread_busy
|
|
384
|
+
knows a request record by its first bytes)."""
|
|
385
|
+
path = self.proj / (sid + ".jsonl")
|
|
386
|
+
write(path, records)
|
|
387
|
+
return path
|
|
388
|
+
|
|
389
|
+
def idle(self, at=None):
|
|
390
|
+
"""A main thread whose last reply has finished (its workflows run on in the
|
|
391
|
+
background): nothing in flight."""
|
|
392
|
+
at = self.launched + 10 if at is None else at
|
|
393
|
+
return [cc_user(at, "run the release workflows"), api_request(at, "req_m1"),
|
|
394
|
+
block(at + 10, "req_m1", TEXT, "end_turn")]
|
|
395
|
+
|
|
396
|
+
def start(self, argv=None, main=None, env=None, hist="", launched=None):
|
|
397
|
+
"""A fresh session -- state, registry, transcript, log -- launched at ``launched``
|
|
398
|
+
(default 1000 s ago: past MEMORY_MIN_UPTIME), the banner on its pane."""
|
|
399
|
+
if launched is not None:
|
|
400
|
+
self.launched = launched
|
|
401
|
+
for path in (self.proj / (SID + ".jsonl"), self.root / "selection.log"):
|
|
402
|
+
if path.exists():
|
|
403
|
+
path.unlink()
|
|
404
|
+
state = self.write_state(["--dangerously-skip-permissions"] if argv is None else argv,
|
|
405
|
+
hist=hist)
|
|
406
|
+
self.registry()
|
|
407
|
+
self.transcript(self.idle() if main is None else main)
|
|
408
|
+
return self.watcher(state, env, MemoryRunner(self.t0, self.PID, self.PPID,
|
|
409
|
+
self.self_path))
|
|
410
|
+
|
|
411
|
+
def stop_at(self, runner, when):
|
|
412
|
+
"""The operator ends the session (/exit: its registry goes)."""
|
|
413
|
+
reg = self.root / "acct-01" / "sessions" / ("%d.json" % self.PID)
|
|
414
|
+
runner.at(when, lambda: (reg.unlink() if reg.exists() else None,
|
|
415
|
+
runner.exit(self.PID)))
|
|
416
|
+
|
|
417
|
+
def assert_untouched(self, runner):
|
|
418
|
+
self.assertEqual((runner.probes, runner.signals, runner.sends, self.relaunch_files()),
|
|
419
|
+
([], [], [], []))
|
|
420
|
+
|
|
421
|
+
def assert_relaunched(self, runner, size="8.3GB"):
|
|
422
|
+
kv, argv = self.relaunch_of(runner)
|
|
423
|
+
self.assertEqual(argv, ["--dangerously-skip-permissions", "--resume", SID,
|
|
424
|
+
ar.resume_prompt("memory", detail=size)])
|
|
425
|
+
self.assertEqual({k: kv[k] for k in ("class", "acct", "reset", "rtype", "sid", "depth",
|
|
426
|
+
"avoid")},
|
|
427
|
+
{"class": "memory", "acct": "acct-01", "reset": "", "rtype": "",
|
|
428
|
+
"sid": SID, "depth": "1", "avoid": ""})
|
|
429
|
+
return kv
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class MemoryWatcherTests(MemoryCase):
|
|
433
|
+
def test_the_banner_moves_the_session_like_a_limit_and_marks_nothing(self):
|
|
434
|
+
w, r = self.start()
|
|
435
|
+
self.assertEqual(w.run(), 0)
|
|
436
|
+
prompt = ar.resume_prompt("memory", detail="8.3GB")
|
|
437
|
+
self.assertEqual(len(r.probes), 1)
|
|
438
|
+
probe = r.probes[0]
|
|
439
|
+
self.assertGreaterEqual(probe["t"] - self.t0, 5.0) # GRACE after the first scan
|
|
440
|
+
self.assertLess(probe["t"] - self.t0, 7.0)
|
|
441
|
+
self.assertEqual(probe["argv"], [self.self_path, "--dangerously-skip-permissions",
|
|
442
|
+
"--resume", SID, prompt])
|
|
443
|
+
# the account it leaves is not avoided: it may take the session back
|
|
444
|
+
self.assertEqual(probe["env"]["CLAUDE_MULTIACC_AR_AVOID"], "")
|
|
445
|
+
self.assertEqual(r.signals, [(self.PID, signal.SIGTERM)])
|
|
446
|
+
self.assert_sequence(r)
|
|
447
|
+
kv = self.assert_relaunched(r)
|
|
448
|
+
self.assertRegex(kv["hist"], r"^memory:\d+$")
|
|
449
|
+
self.assertEqual(self.events(), ["watch", "detect", "switch"])
|
|
450
|
+
self.assertIn(" class=memory code=critical_memory sid=%s acct=acct-01 reset=- size=8.3GB "
|
|
451
|
+
"src=pane" % SID, self.log_lines()[1])
|
|
452
|
+
self.assertIn(" from=acct-01 class=memory sid=%s depth=1 probe=acct-02 size=8.3GB"
|
|
453
|
+
% SID, self.log_lines()[2])
|
|
454
|
+
# the pane is only read until the stop (the scan, and once more right before it); the
|
|
455
|
+
# keys are the relaunch's, into the shell, after the client is gone
|
|
456
|
+
term = r.index(lambda a: a[0] == "<signal>")
|
|
457
|
+
keys = [i for i, c in enumerate(r.calls)
|
|
458
|
+
if c["argv"][0] == "tmux" and "send-keys" in c["argv"]]
|
|
459
|
+
self.assertGreater(min(keys), term)
|
|
460
|
+
self.assertEqual([c["argv"] for c in r.captures], [ar.capture_argv(SOCK, "%7")] * 2)
|
|
461
|
+
self.assertLess(max(i for i, c in enumerate(r.calls) if c in r.captures), term)
|
|
462
|
+
self.assertFalse([p for acct in ("acct-01", "acct-02") for p in (self.root / acct).iterdir()
|
|
463
|
+
if p.name in (".limited", ".expired")])
|
|
464
|
+
|
|
465
|
+
def test_the_account_it_leaves_may_take_it_back(self):
|
|
466
|
+
w, r = self.start()
|
|
467
|
+
r.probe_out = "pick=acct-01 tier=soft\n"
|
|
468
|
+
self.assertEqual(w.run(), 0)
|
|
469
|
+
self.assert_relaunched(r)
|
|
470
|
+
self.assertIn(" class=memory sid=%s depth=1 probe=acct-01 size=8.3GB" % SID,
|
|
471
|
+
self.log_lines()[-1])
|
|
472
|
+
|
|
473
|
+
def test_nowhere_to_go_holds_and_nothing_is_stopped(self):
|
|
474
|
+
w, r = self.start(env={"CLAUDE_MULTIACC_AR_HOLD_REPROBE": "20"})
|
|
475
|
+
r.probe_out = "pick=acct-01 tier=hard\n"
|
|
476
|
+
self.stop_at(r, self.t0 + 50)
|
|
477
|
+
self.assertEqual(w.run(), 0)
|
|
478
|
+
self.assertEqual(len(r.probes), 3) # ~5 s, ~25 s, ~45 s
|
|
479
|
+
self.assertEqual((r.signals, r.sends, self.relaunch_files()), ([], [], []))
|
|
480
|
+
self.assertEqual(self.events(), ["watch", "detect", "hold"])
|
|
481
|
+
self.assertIn(" class=memory sid=%s acct=acct-01 reason=no-room pick=acct-01 tier=hard"
|
|
482
|
+
% SID, self.log_lines()[-1])
|
|
483
|
+
|
|
484
|
+
def test_the_banners_words_in_the_conversation_move_nothing(self):
|
|
485
|
+
w, r = self.start()
|
|
486
|
+
r.screen = screen(None, above=QUOTED)
|
|
487
|
+
self.stop_at(r, self.t0 + 100)
|
|
488
|
+
self.assertEqual(w.run(), 0)
|
|
489
|
+
self.assert_untouched(r)
|
|
490
|
+
self.assertEqual(self.events(), ["watch"])
|
|
491
|
+
self.assertEqual(len(r.captures), 4) # t0, +30, +60, +90: looked, found nothing
|
|
492
|
+
|
|
493
|
+
def test_a_dialog_quoting_the_banner_moves_nothing(self):
|
|
494
|
+
# The main thread asks to run a command that quotes the banner, and nobody answers for
|
|
495
|
+
# twenty minutes: 2.1.304 draws the request in the prompt's place under one rule, and
|
|
496
|
+
# the watcher used to read it as the footer -- a verdict, then the healthy client
|
|
497
|
+
# stopped at MEMORY_BUSY_CAP. The same with the main thread idle (an agent asked) or
|
|
498
|
+
# a plan waiting for approval, which went in GRACE
|
|
499
|
+
t = self.t0
|
|
500
|
+
cmd = "echo '%s'" % QUOTE
|
|
501
|
+
w, r = self.start(main=self.idle() + [
|
|
502
|
+
api_request(t - 60, "req_m2"), block(t - 55, "req_m2", tool_use(command=cmd),
|
|
503
|
+
"tool_use")])
|
|
504
|
+
r.screen = permission_dialog(cmd)
|
|
505
|
+
self.stop_at(r, t + 1200)
|
|
506
|
+
self.assertEqual(w.run(), 0)
|
|
507
|
+
self.assert_untouched(r)
|
|
508
|
+
self.assertEqual(self.events(), ["watch"])
|
|
509
|
+
self.assertEqual(len(r.captures), 40)
|
|
510
|
+
for name, scr in (("dialog", permission_dialog("+ " + BANNER, title="Edit file")),
|
|
511
|
+
("plan", plan_dialog(["1. On " + QUOTE, "2. Restart it"]))):
|
|
512
|
+
with self.subTest(name):
|
|
513
|
+
w, r = self.start()
|
|
514
|
+
r.screen = scr
|
|
515
|
+
self.stop_at(r, t + 100)
|
|
516
|
+
self.assertEqual(w.run(), 0)
|
|
517
|
+
self.assert_untouched(r)
|
|
518
|
+
self.assertEqual(self.events(), ["watch"])
|
|
519
|
+
|
|
520
|
+
def test_never_within_min_uptime_of_the_launch(self):
|
|
521
|
+
# a resumed session can come back as heavy as it left: no restart loop
|
|
522
|
+
w, r = self.start(launched=int(self.t0) - 60)
|
|
523
|
+
self.stop_at(r, self.t0 + 300)
|
|
524
|
+
self.assertEqual(w.run(), 0)
|
|
525
|
+
self.assert_untouched(r)
|
|
526
|
+
self.assertEqual((r.captures, self.events()), ([], ["watch"]))
|
|
527
|
+
# ...and once the launch is that old, the banner still up is acted on
|
|
528
|
+
w, r = self.start(launched=int(self.t0) - 60,
|
|
529
|
+
env={"CLAUDE_MULTIACC_AR_MEMORY_MIN_UPTIME": "300"})
|
|
530
|
+
self.assertEqual(w.run(), 0)
|
|
531
|
+
self.assertGreaterEqual(r.captures[0]["t"], self.t0 + 240)
|
|
532
|
+
self.assertGreaterEqual(r.probes[0]["t"], self.t0 + 245)
|
|
533
|
+
self.assert_relaunched(r)
|
|
534
|
+
|
|
535
|
+
def test_a_busy_main_thread_is_waited_for(self):
|
|
536
|
+
# its tool runs for four minutes: the session moves when it has its result
|
|
537
|
+
t = self.t0
|
|
538
|
+
w, r = self.start(main=self.idle() + [api_request(t - 60, "req_m2"),
|
|
539
|
+
block(t - 55, "req_m2", tool_use(), "tool_use")])
|
|
540
|
+
r.at(t + 240, lambda: self.transcript([result_of(t + 240)]))
|
|
541
|
+
self.assertEqual(w.run(), 0)
|
|
542
|
+
self.assertEqual(len(r.probes), 1)
|
|
543
|
+
self.assertGreaterEqual(r.probes[0]["t"], t + 240)
|
|
544
|
+
self.assertLess(r.probes[0]["t"], t + 245)
|
|
545
|
+
self.assertEqual(self.events(), ["watch", "detect", "hold", "switch"])
|
|
546
|
+
self.assertIn(" class=memory sid=%s acct=acct-01 reason=main-tool" % SID,
|
|
547
|
+
self.log_lines()[2])
|
|
548
|
+
self.assert_relaunched(r)
|
|
549
|
+
|
|
550
|
+
def test_a_reply_still_streaming_or_a_request_in_flight_waits_too(self):
|
|
551
|
+
t = self.t0
|
|
552
|
+
for busy, records, done in (
|
|
553
|
+
("stream", [api_request(t - 20, "req_m2"), block(t - 15, "req_m2", TEXT, None)],
|
|
554
|
+
[block(t + 100, "req_m2", TEXT, "end_turn")]),
|
|
555
|
+
("request", [cc_user(t - 3, "and the docs?"), api_request(t - 3, "req_m2")],
|
|
556
|
+
[block(t + 100, "req_m2", TEXT, "end_turn")])):
|
|
557
|
+
with self.subTest(busy=busy):
|
|
558
|
+
w, r = self.start(main=self.idle() + records)
|
|
559
|
+
r.at(t + 100, lambda done=done: self.transcript(done))
|
|
560
|
+
self.assertEqual(w.run(), 0)
|
|
561
|
+
self.assertGreaterEqual(r.probes[0]["t"], t + 100)
|
|
562
|
+
self.assertLess(r.probes[0]["t"], t + 105)
|
|
563
|
+
self.assertIn(" reason=main-%s" % busy, self.log_lines()[2])
|
|
564
|
+
|
|
565
|
+
def test_the_wait_is_capped_the_process_may_not_last(self):
|
|
566
|
+
# the tool runs on: MEMORY_BUSY_CAP after the wait began (the first decision, GRACE
|
|
567
|
+
# after the detection) the session moves anyway
|
|
568
|
+
t = self.t0
|
|
569
|
+
w, r = self.start(main=self.idle() + [api_request(t - 60, "req_m2"),
|
|
570
|
+
block(t - 55, "req_m2", tool_use(), "tool_use")],
|
|
571
|
+
env={"CLAUDE_MULTIACC_AR_MEMORY_BUSY_CAP": "120"})
|
|
572
|
+
self.assertEqual(w.run(), 0)
|
|
573
|
+
self.assertGreaterEqual(r.probes[0]["t"], t + 5 + 120)
|
|
574
|
+
self.assertLess(r.probes[0]["t"], t + 5 + 123)
|
|
575
|
+
self.assertEqual(self.events(), ["watch", "detect", "hold", "switch"])
|
|
576
|
+
self.assert_relaunched(r)
|
|
577
|
+
|
|
578
|
+
def term_at(self, runner):
|
|
579
|
+
return [c["t"] - self.t0 for c in runner.calls
|
|
580
|
+
if c["argv"][:3] == ["<signal>", self.PID, signal.SIGTERM]]
|
|
581
|
+
|
|
582
|
+
def test_a_hold_for_room_spends_none_of_the_wait_for_the_main_thread(self):
|
|
583
|
+
# The wait used to be counted from the detection: eleven minutes without room, then a
|
|
584
|
+
# tool the user started was cut off the moment an account freed up. It is counted from
|
|
585
|
+
# when the main thread was found busy, so the tool is waited for
|
|
586
|
+
t = self.t0
|
|
587
|
+
w, r = self.start()
|
|
588
|
+
r.probe_out = "pick=acct-01 tier=hard\n" # no room: held, probed again every minute
|
|
589
|
+
tpath = self.proj / (SID + ".jsonl")
|
|
590
|
+
r.at(t + 650, lambda: write(tpath, [cc_user(t + 650, "run the migration"),
|
|
591
|
+
api_request(t + 650, "req_m3"),
|
|
592
|
+
block(t + 652, "req_m3", tool_use(), "tool_use")]))
|
|
593
|
+
r.at(t + 660, lambda: setattr(r, "probe_out", "pick=acct-02 tier=eligible\n"))
|
|
594
|
+
r.at(t + 900, lambda: write(tpath, [result_of(t + 900)]))
|
|
595
|
+
self.assertEqual(w.run(), 0)
|
|
596
|
+
term = self.term_at(r)
|
|
597
|
+
self.assertEqual(len(term), 1)
|
|
598
|
+
self.assertGreaterEqual(term[0], 900)
|
|
599
|
+
self.assertLess(term[0], 905)
|
|
600
|
+
holds = [line for line in self.log_lines() if " hold " in line]
|
|
601
|
+
self.assertIn(" reason=no-room ", holds[0])
|
|
602
|
+
self.assertIn(" class=memory sid=%s acct=acct-01 reason=main-tool" % SID, holds[-1])
|
|
603
|
+
self.assert_relaunched(r)
|
|
604
|
+
# ...for MEMORY_BUSY_CAP at most, counted from then: a tool that runs on is cut off
|
|
605
|
+
w, r = self.start(env={"CLAUDE_MULTIACC_AR_MEMORY_BUSY_CAP": "200"})
|
|
606
|
+
r.probe_out = "pick=acct-01 tier=hard\n"
|
|
607
|
+
r.at(t + 650, lambda: write(tpath, [cc_user(t + 650, "run the migration"),
|
|
608
|
+
api_request(t + 650, "req_m3"),
|
|
609
|
+
block(t + 652, "req_m3", tool_use(), "tool_use")]))
|
|
610
|
+
r.at(t + 660, lambda: setattr(r, "probe_out", "pick=acct-02 tier=eligible\n"))
|
|
611
|
+
self.assertEqual(w.run(), 0)
|
|
612
|
+
term = self.term_at(r)
|
|
613
|
+
self.assertGreaterEqual(term[0], 850)
|
|
614
|
+
self.assertLess(term[0], 855)
|
|
615
|
+
|
|
616
|
+
def test_each_busy_stretch_has_its_own_cap(self):
|
|
617
|
+
# held for want of room through a first tool (+100..+300), the main thread idle, then a
|
|
618
|
+
# second tool from +500; room at +520: the second one is waited for, from +500
|
|
619
|
+
t = self.t0
|
|
620
|
+
w, r = self.start(env={"CLAUDE_MULTIACC_AR_MEMORY_BUSY_CAP": "300"})
|
|
621
|
+
r.probe_out = "pick=acct-01 tier=hard\n"
|
|
622
|
+
tpath = self.proj / (SID + ".jsonl")
|
|
623
|
+
for begin, end, rid, tid in ((100, 300, "req_m3", "toolu_3"),
|
|
624
|
+
(500, 700, "req_m4", "toolu_4")):
|
|
625
|
+
r.at(t + begin, lambda begin=begin, rid=rid, tid=tid: write(tpath, [
|
|
626
|
+
cc_user(t + begin, "and now"), api_request(t + begin, rid),
|
|
627
|
+
block(t + begin + 2, rid, tool_use(tid), "tool_use", mid="msg_" + rid)]))
|
|
628
|
+
r.at(t + end, lambda end=end, tid=tid: write(tpath, [result_of(t + end, tid)]))
|
|
629
|
+
r.at(t + 520, lambda: setattr(r, "probe_out", "pick=acct-02 tier=eligible\n"))
|
|
630
|
+
self.assertEqual(w.run(), 0)
|
|
631
|
+
term = self.term_at(r)
|
|
632
|
+
self.assertGreaterEqual(term[0], 700)
|
|
633
|
+
self.assertLess(term[0], 705)
|
|
634
|
+
self.assert_relaunched(r)
|
|
635
|
+
|
|
636
|
+
def test_a_transcript_verdict_in_between_starts_the_wait_over(self):
|
|
637
|
+
# waiting for a tool from the start; at +200 its next request fails (a transient error,
|
|
638
|
+
# held by its spent budget, goes first); at +400 the user types and that reply streams
|
|
639
|
+
# until +900: waited for from +400, not from the tool's wait of before
|
|
640
|
+
t = int(self.t0)
|
|
641
|
+
hist = ",".join("transient:%d" % (t - 600 - i) for i in range(3))
|
|
642
|
+
w, r = self.start(main=self.idle() + [api_request(t - 60, "req_m2"),
|
|
643
|
+
block(t - 55, "req_m2", tool_use(), "tool_use")],
|
|
644
|
+
hist=hist)
|
|
645
|
+
tpath = self.proj / (SID + ".jsonl")
|
|
646
|
+
r.at(t + 200, lambda: write(tpath, [result_of(t + 200), api_request(t + 200, "req_m3"),
|
|
647
|
+
cc_api_error(t + 201, "overloaded")]))
|
|
648
|
+
r.at(t + 400, lambda: write(tpath, [cc_user(t + 400, "try again please"),
|
|
649
|
+
api_request(t + 400, "req_m4")]))
|
|
650
|
+
r.at(t + 900, lambda: write(tpath, [block(t + 900, "req_m4", TEXT, "end_turn")]))
|
|
651
|
+
self.assertEqual(w.run(), 0)
|
|
652
|
+
term = self.term_at(r)
|
|
653
|
+
self.assertGreaterEqual(term[0], 900)
|
|
654
|
+
self.assertLess(term[0], 905)
|
|
655
|
+
self.assertEqual(self.relaunch_of(r)[0]["class"], "memory")
|
|
656
|
+
|
|
657
|
+
def test_a_compaction_after_a_long_hold_is_waited_for(self):
|
|
658
|
+
# /compact started after the verdict had been held for want of room has the cap of its
|
|
659
|
+
# own, counted from the first reading that showed its spinner
|
|
660
|
+
t = self.t0
|
|
661
|
+
w, r = self.start()
|
|
662
|
+
r.probe_out = "pick=acct-01 tier=hard\n"
|
|
663
|
+
r.at(t + 650, lambda: setattr(r, "screen", screen(spinner=COMPACTING)))
|
|
664
|
+
r.at(t + 660, lambda: setattr(r, "probe_out", "pick=acct-02 tier=eligible\n"))
|
|
665
|
+
r.at(t + 800, lambda: setattr(r, "screen", screen())) # done; still over the line
|
|
666
|
+
self.assertEqual(w.run(), 0)
|
|
667
|
+
term = self.term_at(r)
|
|
668
|
+
self.assertGreaterEqual(term[0], 810) # the first scan after it ended (every 30 s)
|
|
669
|
+
self.assertLess(term[0], 815)
|
|
670
|
+
self.assertIn(" reason=main-compact", [line for line in self.log_lines()
|
|
671
|
+
if " hold " in line][-1])
|
|
672
|
+
self.assert_relaunched(r)
|
|
673
|
+
|
|
674
|
+
def test_work_started_behind_a_transcript_verdict_is_waited_for(self):
|
|
675
|
+
# a transient error held by its budget goes first; the user types at +700, which ends
|
|
676
|
+
# it, and the reply to that is streamed for 500 s: the memory verdict waits for it
|
|
677
|
+
t = int(self.t0)
|
|
678
|
+
hist = ",".join("transient:%d" % (t - 600 - i) for i in range(3))
|
|
679
|
+
w, r = self.start(main=self.idle() + [cc_api_error(t, "overloaded")], hist=hist)
|
|
680
|
+
tpath = self.proj / (SID + ".jsonl")
|
|
681
|
+
r.at(t + 700, lambda: write(tpath, [cc_user(t + 700, "try again please"),
|
|
682
|
+
api_request(t + 700, "req_m4")]))
|
|
683
|
+
r.at(t + 1200, lambda: write(tpath, [block(t + 1200, "req_m4", TEXT, "end_turn")]))
|
|
684
|
+
self.assertEqual(w.run(), 0)
|
|
685
|
+
term = self.term_at(r)
|
|
686
|
+
self.assertGreaterEqual(term[0], 1200)
|
|
687
|
+
self.assertLess(term[0], 1205)
|
|
688
|
+
self.assertTrue([line for line in self.log_lines() if " reason=transient-budget" in line])
|
|
689
|
+
self.assertIn(" class=memory sid=%s acct=acct-01 reason=main-request" % SID,
|
|
690
|
+
[line for line in self.log_lines() if " hold " in line][-1])
|
|
691
|
+
self.assertEqual(self.relaunch_of(r)[0]["class"], "memory")
|
|
692
|
+
|
|
693
|
+
def test_a_compaction_under_way_is_waited_for(self):
|
|
694
|
+
# /compact, the banner's other remedy, shows in no transcript while it runs: the
|
|
695
|
+
# spinner says it. Once it is over the banner decides: still up, the session moves...
|
|
696
|
+
t = self.t0
|
|
697
|
+
w, r = self.start()
|
|
698
|
+
r.screen = screen(spinner=COMPACTING)
|
|
699
|
+
r.at(t + 100, lambda: setattr(r, "screen", screen()))
|
|
700
|
+
self.assertEqual(w.run(), 0)
|
|
701
|
+
self.assertGreaterEqual(r.probes[0]["t"], t + 120) # the first scan after it ended
|
|
702
|
+
self.assertLess(r.probes[0]["t"], t + 122)
|
|
703
|
+
self.assertEqual(self.events(), ["watch", "detect", "hold", "switch"])
|
|
704
|
+
self.assertIn(" class=memory sid=%s acct=acct-01 reason=main-compact" % SID,
|
|
705
|
+
self.log_lines()[2])
|
|
706
|
+
self.assert_relaunched(r)
|
|
707
|
+
# ...gone (the heap back under the line), nothing does
|
|
708
|
+
w, r = self.start()
|
|
709
|
+
r.screen = screen(spinner=COMPACTING)
|
|
710
|
+
r.at(t + 100, lambda: setattr(r, "screen", screen(None)))
|
|
711
|
+
self.stop_at(r, t + 200)
|
|
712
|
+
self.assertEqual(w.run(), 0)
|
|
713
|
+
self.assert_untouched(r)
|
|
714
|
+
# ...and one longer than MEMORY_BUSY_CAP is not waited out
|
|
715
|
+
w, r = self.start(env={"CLAUDE_MULTIACC_AR_MEMORY_BUSY_CAP": "90"})
|
|
716
|
+
r.screen = screen(spinner=COMPACTING)
|
|
717
|
+
self.assertEqual(w.run(), 0)
|
|
718
|
+
self.assertGreaterEqual(r.probes[0]["t"], t + 90)
|
|
719
|
+
self.assertLess(r.probes[0]["t"], t + 95)
|
|
720
|
+
self.assert_relaunched(r)
|
|
721
|
+
|
|
722
|
+
def test_a_compact_started_during_the_probe_is_not_cut_off(self):
|
|
723
|
+
t = self.t0
|
|
724
|
+
w, r = self.start()
|
|
725
|
+
real_run = r.run
|
|
726
|
+
|
|
727
|
+
def run(argv, timeout, env=None, cwd=None):
|
|
728
|
+
if argv[0] == self.self_path:
|
|
729
|
+
r.screen = screen(spinner=COMPACTING)
|
|
730
|
+
r.at(r.t + 60, lambda: setattr(r, "screen", screen(None)))
|
|
731
|
+
return real_run(argv, timeout, env, cwd)
|
|
732
|
+
r.run = run
|
|
733
|
+
self.stop_at(r, t + 150)
|
|
734
|
+
self.assertEqual(w.run(), 0)
|
|
735
|
+
self.assertEqual(len(r.probes), 1)
|
|
736
|
+
self.assertEqual((r.signals, r.sends, self.relaunch_files()), ([], [], []))
|
|
737
|
+
self.assertIn(" reason=main-compact", self.log_lines()[-1])
|
|
738
|
+
|
|
739
|
+
def test_a_banner_gone_before_the_stop_moves_nothing(self):
|
|
740
|
+
# /compact while the probe runs: the look right before the stop finds no banner
|
|
741
|
+
w, r = self.start()
|
|
742
|
+
real_run = r.run
|
|
743
|
+
|
|
744
|
+
def run(argv, timeout, env=None, cwd=None):
|
|
745
|
+
if argv[0] == self.self_path:
|
|
746
|
+
r.screen = screen(None)
|
|
747
|
+
return real_run(argv, timeout, env, cwd)
|
|
748
|
+
r.run = run
|
|
749
|
+
self.stop_at(r, self.t0 + 60)
|
|
750
|
+
self.assertEqual(w.run(), 0)
|
|
751
|
+
self.assertEqual(len(r.probes), 1)
|
|
752
|
+
self.assertEqual((r.signals, r.sends, self.relaunch_files()), ([], [], []))
|
|
753
|
+
self.assertIsNone(w.memory)
|
|
754
|
+
# gone while the main thread is still busy: the next scan ends the verdict, and the
|
|
755
|
+
# tool finishing later moves nothing
|
|
756
|
+
t = self.t0
|
|
757
|
+
w, r = self.start(main=self.idle() + [api_request(t - 60, "req_m2"),
|
|
758
|
+
block(t - 55, "req_m2", tool_use(), "tool_use")])
|
|
759
|
+
r.at(t + 20, lambda: setattr(r, "screen", screen(None)))
|
|
760
|
+
r.at(t + 40, lambda: self.transcript([result_of(t + 40)]))
|
|
761
|
+
self.stop_at(r, t + 120)
|
|
762
|
+
self.assertEqual(w.run(), 0)
|
|
763
|
+
self.assert_untouched(r)
|
|
764
|
+
|
|
765
|
+
def test_a_banner_back_after_it_went_is_a_new_verdict(self):
|
|
766
|
+
t = self.t0
|
|
767
|
+
w, r = self.start()
|
|
768
|
+
r.screen = screen(None)
|
|
769
|
+
r.at(t + 50, lambda: setattr(r, "screen", screen()))
|
|
770
|
+
self.assertEqual(w.run(), 0)
|
|
771
|
+
self.assertGreaterEqual(r.probes[0]["t"], t + 65) # seen at the +60 scan, then GRACE
|
|
772
|
+
self.assertLess(r.probes[0]["t"], t + 67)
|
|
773
|
+
self.assert_relaunched(r)
|
|
774
|
+
|
|
775
|
+
def test_a_banner_back_after_it_went_is_held_and_probed_afresh(self):
|
|
776
|
+
# held for want of room at +5, gone at the +30 scan, back at the +60 one: the new
|
|
777
|
+
# verdict is probed GRACE after it (not HOLD_REPROBE after the old one's probe), and
|
|
778
|
+
# its own hold is logged
|
|
779
|
+
t = self.t0
|
|
780
|
+
w, r = self.start(env={"CLAUDE_MULTIACC_AR_HOLD_REPROBE": "120"})
|
|
781
|
+
r.probe_out = "pick=acct-01 tier=hard\n"
|
|
782
|
+
r.at(t + 20, lambda: setattr(r, "screen", screen(None)))
|
|
783
|
+
r.at(t + 50, lambda: setattr(r, "screen", screen()))
|
|
784
|
+
self.stop_at(r, t + 100)
|
|
785
|
+
self.assertEqual(w.run(), 0)
|
|
786
|
+
self.assertEqual([round(p["t"] - t) for p in r.probes], [5, 65])
|
|
787
|
+
self.assertEqual(self.events(), ["watch", "detect", "hold", "detect", "hold"])
|
|
788
|
+
self.assertIn(" class=memory sid=%s acct=acct-01 reason=no-room" % SID,
|
|
789
|
+
self.log_lines()[-1])
|
|
790
|
+
self.assertEqual((r.signals, r.sends, self.relaunch_files()), ([], [], []))
|
|
791
|
+
|
|
792
|
+
def test_at_most_memory_per_hour_then_the_watch_gives_up_on_it(self):
|
|
793
|
+
# two memory relaunches of this chain in the last hour: this process is left as it is,
|
|
794
|
+
# once, and the watcher goes on with the transcript's own verdicts
|
|
795
|
+
t = int(self.t0)
|
|
796
|
+
hist = "memory:%d,memory:%d" % (t - 3000, t - 1200)
|
|
797
|
+
w, r = self.start(hist=hist)
|
|
798
|
+
tpath = self.proj / (SID + ".jsonl")
|
|
799
|
+
r.at(t + 60, lambda: write(tpath, [cc_quota(t + 60, resets=t + 9000)]))
|
|
800
|
+
self.assertEqual(w.run(), 0)
|
|
801
|
+
self.assertEqual(self.events(), ["watch", "detect", "giveup", "detect", "switch"])
|
|
802
|
+
self.assertIn(" reason=memory-budget sid=%s acct=acct-01 class=memory" % SID,
|
|
803
|
+
self.log_lines()[2])
|
|
804
|
+
self.assertEqual(len(r.probes), 1)
|
|
805
|
+
self.assertGreaterEqual(r.probes[0]["t"], t + 65)
|
|
806
|
+
kv, _ = self.relaunch_of(r)
|
|
807
|
+
self.assertEqual((kv["class"], kv["hist"].split(",")[:2]), ("quota", hist.split(",")))
|
|
808
|
+
self.assertEqual(len(r.captures), 1) # the scan that found it, and none after
|
|
809
|
+
|
|
810
|
+
def test_a_client_that_ignores_sigterm_still_gets_its_relaunch(self):
|
|
811
|
+
# 2026-10-20: a client at 8.3GB ignored SIGTERM for over 15 s. KILL after TERM_GRACE;
|
|
812
|
+
# the relaunch is typed once it is gone, however long it then takes to go
|
|
813
|
+
for after_kill in (0, 6):
|
|
814
|
+
with self.subTest(after_kill=after_kill):
|
|
815
|
+
w, r = self.start()
|
|
816
|
+
r.term_kills = False
|
|
817
|
+
real_signal = r.signal
|
|
818
|
+
|
|
819
|
+
def slow_kill(pid, sig, r=r, real_signal=real_signal, after_kill=after_kill):
|
|
820
|
+
if pid == self.PID and sig == signal.SIGKILL and after_kill:
|
|
821
|
+
r.stuck.add(pid) # the kernel takes its time tearing 8 GB down
|
|
822
|
+
r.at(r.t + after_kill, lambda: (r.stuck.discard(pid), r.exit(pid)))
|
|
823
|
+
return real_signal(pid, sig)
|
|
824
|
+
r.signal = slow_kill
|
|
825
|
+
self.assertEqual(w.run(), 0)
|
|
826
|
+
self.assertEqual(r.signals, [(self.PID, signal.SIGTERM),
|
|
827
|
+
(self.PID, signal.SIGKILL)])
|
|
828
|
+
term = r.index(lambda a: a[:3] == ["<signal>", self.PID, signal.SIGTERM])
|
|
829
|
+
kill = r.index(lambda a: a[:3] == ["<signal>", self.PID, signal.SIGKILL])
|
|
830
|
+
typed = r.index(lambda a: a[0] == "tmux" and "-l" in a)
|
|
831
|
+
self.assertGreaterEqual(r.calls[kill]["t"], r.calls[term]["t"] + 10) # TERM_GRACE
|
|
832
|
+
self.assertGreaterEqual(r.calls[typed]["t"], r.calls[kill]["t"] + after_kill)
|
|
833
|
+
self.assert_sequence(r)
|
|
834
|
+
self.assert_relaunched(r)
|
|
835
|
+
self.assertEqual(self.events()[-1], "switch")
|
|
836
|
+
|
|
837
|
+
def test_a_client_that_takes_its_time_on_sigterm_is_waited_for(self):
|
|
838
|
+
w, r = self.start()
|
|
839
|
+
r.term_kills = False
|
|
840
|
+
real_signal = r.signal
|
|
841
|
+
|
|
842
|
+
def slow_term(pid, sig):
|
|
843
|
+
if pid == self.PID and sig == signal.SIGTERM:
|
|
844
|
+
r.at(r.t + 8, lambda: r.exit(pid)) # flushes its workflows' state, then goes
|
|
845
|
+
return real_signal(pid, sig)
|
|
846
|
+
r.signal = slow_term
|
|
847
|
+
self.assertEqual(w.run(), 0)
|
|
848
|
+
self.assertEqual(r.signals, [(self.PID, signal.SIGTERM)])
|
|
849
|
+
self.assert_sequence(r)
|
|
850
|
+
self.assert_relaunched(r)
|
|
851
|
+
|
|
852
|
+
def test_a_pane_that_is_not_the_clients_screen_is_never_read(self):
|
|
853
|
+
for name, setup in (("in the background", lambda r: setattr(r, "client_stat", "S")),
|
|
854
|
+
("suspended", lambda r: setattr(r, "client_stat", "T+")),
|
|
855
|
+
("another pane", lambda r: setattr(r, "pane_pid", 31337))):
|
|
856
|
+
with self.subTest(name):
|
|
857
|
+
w, r = self.start()
|
|
858
|
+
setup(r)
|
|
859
|
+
self.stop_at(r, self.t0 + 100)
|
|
860
|
+
self.assertEqual(w.run(), 0)
|
|
861
|
+
self.assert_untouched(r)
|
|
862
|
+
self.assertEqual(r.captures, [])
|
|
863
|
+
|
|
864
|
+
def test_a_session_the_relaunch_cannot_name_is_not_read(self):
|
|
865
|
+
state = self.write_state(["--dangerously-skip-permissions"])
|
|
866
|
+
w, r = self.watcher(state, None, MemoryRunner(self.t0, self.PID, self.PPID,
|
|
867
|
+
self.self_path))
|
|
868
|
+
r.at(self.t0 + 70, lambda: r.exit(self.PID)) # no registry: no session id
|
|
869
|
+
self.assertEqual(w.run(), 0)
|
|
870
|
+
self.assertEqual((r.captures, r.probes), ([], []))
|
|
871
|
+
|
|
872
|
+
def test_the_transcripts_verdict_goes_first(self):
|
|
873
|
+
t = int(self.t0)
|
|
874
|
+
w, r = self.start(main=self.idle() + [cc_quota(t, resets=t + 9000)])
|
|
875
|
+
self.assertEqual(w.run(), 0)
|
|
876
|
+
self.assertEqual(len(r.probes), 1)
|
|
877
|
+
kv, _ = self.relaunch_of(r)
|
|
878
|
+
self.assertEqual((kv["class"], kv["avoid"]), ("quota", "acct-01:%d" % (t + 9000)))
|
|
879
|
+
self.assertEqual(self.events(), ["watch", "detect", "detect", "switch"])
|
|
880
|
+
self.assertIn(" class=quota ", self.log_lines()[-1])
|
|
881
|
+
# a transient error's ladder holds it back for 30 s: the memory verdict, older and
|
|
882
|
+
# ready, waits behind it all the same (one relaunch, the transcript's)
|
|
883
|
+
w, r = self.start(main=self.idle() + [cc_api_error(t, "overloaded")])
|
|
884
|
+
r.probe_out = "pick=acct-01 tier=soft\n"
|
|
885
|
+
self.assertEqual(w.run(), 0)
|
|
886
|
+
self.assertGreaterEqual(r.probes[0]["t"], t + 30)
|
|
887
|
+
self.assertEqual(self.relaunch_of(r)[0]["class"], "transient")
|
|
888
|
+
|
|
889
|
+
def test_a_limit_read_during_the_memory_probe_goes_first(self):
|
|
890
|
+
# The account's limit is written while the memory verdict's probe runs. The check
|
|
891
|
+
# right before the stop finds it: the memory verdict stands aside, the pane is not
|
|
892
|
+
# read again, and the session moves for the limit, its account avoided until the reset
|
|
893
|
+
t = int(self.t0)
|
|
894
|
+
w, r = self.start()
|
|
895
|
+
tpath = self.proj / (SID + ".jsonl")
|
|
896
|
+
real_run = r.run
|
|
897
|
+
|
|
898
|
+
def run(argv, timeout, env=None, cwd=None):
|
|
899
|
+
if argv[0] == self.self_path and not r.probes:
|
|
900
|
+
write(tpath, [cc_quota(t + 5, resets=t + 9000)])
|
|
901
|
+
return real_run(argv, timeout, env, cwd)
|
|
902
|
+
r.run = run
|
|
903
|
+
self.assertEqual(w.run(), 0)
|
|
904
|
+
self.assertEqual(len(r.probes), 2) # the memory verdict's, then the limit's
|
|
905
|
+
self.assertEqual(len(r.captures), 1) # the scan that found the banner, and no other
|
|
906
|
+
self.assertEqual(r.signals, [(self.PID, signal.SIGTERM)])
|
|
907
|
+
kv, argv = self.relaunch_of(r)
|
|
908
|
+
self.assertEqual((kv["class"], kv["avoid"]), ("quota", "acct-01:%d" % (t + 9000)))
|
|
909
|
+
self.assertEqual(argv[-1], ar.resume_prompt("quota"))
|
|
910
|
+
self.assertEqual(self.events(), ["watch", "detect", "detect", "switch"])
|
|
911
|
+
self.assertIn(" class=quota ", self.log_lines()[-1])
|
|
912
|
+
|
|
913
|
+
def test_the_transcript_is_read_to_its_end_before_the_main_thread_counts_as_idle(self):
|
|
914
|
+
# A resumed session's transcript takes several reads. The first ones end on a turn
|
|
915
|
+
# that finished; its end has a tool still running, and that is what the memory
|
|
916
|
+
# verdict waits for, not the idle turn the reads so far end on
|
|
917
|
+
t = self.t0
|
|
918
|
+
filler = [cc_reply(self.launched + 30 + i, "y" * 2000) for i in range(60)]
|
|
919
|
+
w, r = self.start(main=self.idle() + filler + [
|
|
920
|
+
api_request(t - 60, "req_m2"), block(t - 55, "req_m2", tool_use(), "tool_use")])
|
|
921
|
+
r.at(t + 240, lambda: self.transcript([result_of(t + 240)]))
|
|
922
|
+
with mock.patch.object(ar.JsonlTail, "MAX_READ", 4096):
|
|
923
|
+
self.assertEqual(w.run(), 0)
|
|
924
|
+
term = self.term_at(r)
|
|
925
|
+
self.assertEqual(len(term), 1)
|
|
926
|
+
self.assertGreaterEqual(term[0], 240)
|
|
927
|
+
self.assertLess(term[0], 245)
|
|
928
|
+
self.assertEqual(self.events(), ["watch", "detect", "hold", "switch"])
|
|
929
|
+
self.assertIn(" class=memory sid=%s acct=acct-01 reason=main-tool" % SID,
|
|
930
|
+
self.log_lines()[2])
|
|
931
|
+
self.assert_relaunched(r)
|
|
932
|
+
|
|
933
|
+
def test_a_banner_that_comes_and_goes_never_re_probes_a_transcript_verdict(self):
|
|
934
|
+
# A limit held for want of room is probed again every HOLD_REPROBE (60 s), its hold
|
|
935
|
+
# logged once. A banner up on one scan and gone on the next (a heap near the line), or
|
|
936
|
+
# a pane read that fails every other scan, used to re-arm it each time: a probe and a
|
|
937
|
+
# hold line every 30 s. Only the memory verdict's own state comes and goes with it
|
|
938
|
+
t = int(self.t0)
|
|
939
|
+
for flip in ("banner", "read"):
|
|
940
|
+
with self.subTest(flip):
|
|
941
|
+
w, r = self.start(main=self.idle() + [cc_quota(t, resets=t + 9000)])
|
|
942
|
+
r.probe_out = "pick=acct-01 tier=hard\n"
|
|
943
|
+
real_run = r.run
|
|
944
|
+
|
|
945
|
+
def run(argv, timeout, env=None, cwd=None, r=r, real_run=real_run, flip=flip):
|
|
946
|
+
rc, out = real_run(argv, timeout, env, cwd)
|
|
947
|
+
if argv[0] == "tmux" and "capture-pane" in argv and len(r.captures) % 2 == 0:
|
|
948
|
+
return (1, "") if flip == "read" else (0, screen(None))
|
|
949
|
+
return rc, out
|
|
950
|
+
r.run = run
|
|
951
|
+
self.stop_at(r, t + 300)
|
|
952
|
+
self.assertEqual(w.run(), 0)
|
|
953
|
+
self.assertEqual([round(p["t"] - t) for p in r.probes], [5, 65, 125, 185, 245])
|
|
954
|
+
holds = [line for line in self.log_lines() if " hold " in line]
|
|
955
|
+
self.assertEqual(len(holds), 1)
|
|
956
|
+
self.assertIn(" class=quota sid=%s acct=acct-01 reason=no-room" % SID, holds[0])
|
|
957
|
+
self.assertEqual(len([line for line in self.log_lines()
|
|
958
|
+
if " detect " in line and " class=memory " in line]), 5)
|
|
959
|
+
self.assertEqual((r.signals, r.sends, self.relaunch_files()), ([], [], []))
|
|
960
|
+
|
|
961
|
+
REFUSALS = ("pane-shell", "pgrp", "holder", "sync", "mode", "cwd", "write", "pane",
|
|
962
|
+
"background")
|
|
963
|
+
|
|
964
|
+
def refusing(self, r, why):
|
|
965
|
+
"""Make the check ``why`` refuse the stop; returns what undoes it. The pane and the
|
|
966
|
+
foreground turn bad only right after the look before the stop: memory_reading checks
|
|
967
|
+
both itself, so earlier they would end the verdict before it ever got to a stop."""
|
|
968
|
+
if why == "pane-shell":
|
|
969
|
+
r.shell_comm = "fish"
|
|
970
|
+
return lambda: setattr(r, "shell_comm", "-zsh")
|
|
971
|
+
if why == "pgrp":
|
|
972
|
+
r.live.add(self.PPID)
|
|
973
|
+
r.pgids[self.PID] = self.PPID
|
|
974
|
+
return lambda: r.pgids.pop(self.PID)
|
|
975
|
+
if why == "holder":
|
|
976
|
+
other = self.root / "acct-02" / "sessions" / "6060.json"
|
|
977
|
+
other.write_text(json.dumps({"pid": 6060, "sessionId": SID, "cwd": str(self.work),
|
|
978
|
+
"startedAt": self.launched * 1000 + 700}))
|
|
979
|
+
r.live.add(6060)
|
|
980
|
+
r.starts[6060] = time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(self.launched))
|
|
981
|
+
return lambda: (r.live.discard(6060), other.unlink())
|
|
982
|
+
if why == "sync":
|
|
983
|
+
r.pane_sync = 1
|
|
984
|
+
return lambda: setattr(r, "pane_sync", 0)
|
|
985
|
+
if why == "mode":
|
|
986
|
+
r.pane_mode, r.mode_sticky = 2, True
|
|
987
|
+
return lambda: setattr(r, "mode_sticky", False)
|
|
988
|
+
if why == "cwd":
|
|
989
|
+
self.work.rmdir()
|
|
990
|
+
return self.work.mkdir
|
|
991
|
+
if why == "write":
|
|
992
|
+
return lambda: None # write_relaunch fails once (the test's patch)
|
|
993
|
+
real_run = r.run
|
|
994
|
+
|
|
995
|
+
def run(argv, timeout, env=None, cwd=None):
|
|
996
|
+
out = real_run(argv, timeout, env, cwd)
|
|
997
|
+
if argv[0] == "tmux" and "capture-pane" in argv and len(r.captures) == 2:
|
|
998
|
+
if why == "pane":
|
|
999
|
+
r.pane_pid = 31337
|
|
1000
|
+
else:
|
|
1001
|
+
r.client_stat = "T+"
|
|
1002
|
+
return out
|
|
1003
|
+
r.run = run
|
|
1004
|
+
return lambda: (setattr(r, "pane_pid", self.PPID), setattr(r, "client_stat", "S+"))
|
|
1005
|
+
|
|
1006
|
+
def test_a_check_that_refuses_the_stop_drops_only_the_memory_verdict(self):
|
|
1007
|
+
# A check before the stop refuses it (+5): the memory verdict alone is given up, once,
|
|
1008
|
+
# and the pane is not read for MEMORY_BACKOFF (600 s). The watcher used to exit there,
|
|
1009
|
+
# and with it the session's every other watch: the limit at +100 moves it all the same
|
|
1010
|
+
t = int(self.t0)
|
|
1011
|
+
tpath = self.proj / (SID + ".jsonl")
|
|
1012
|
+
real_write = ar.write_relaunch
|
|
1013
|
+
for why in self.REFUSALS:
|
|
1014
|
+
with self.subTest(why):
|
|
1015
|
+
self.work.mkdir(exist_ok=True) # whatever an earlier case left
|
|
1016
|
+
w, r = self.start()
|
|
1017
|
+
undo = self.refusing(r, why)
|
|
1018
|
+
r.at(t + 60, undo)
|
|
1019
|
+
r.at(t + 100, lambda: write(tpath, [cc_quota(t + 100, resets=t + 9000)]))
|
|
1020
|
+
writes = []
|
|
1021
|
+
|
|
1022
|
+
def flaky_write(*args, why=why, writes=writes):
|
|
1023
|
+
writes.append(args)
|
|
1024
|
+
if why == "write" and len(writes) == 1:
|
|
1025
|
+
raise OSError("No space left on device")
|
|
1026
|
+
return real_write(*args)
|
|
1027
|
+
with mock.patch.object(ar, "write_relaunch", flaky_write):
|
|
1028
|
+
self.assertEqual(w.run(), 0)
|
|
1029
|
+
self.assertEqual(self.events(), ["watch", "detect", "giveup", "detect", "switch"])
|
|
1030
|
+
self.assertIn(" giveup provider=claude chain=%s reason=memory-%s sid=%s "
|
|
1031
|
+
"acct=acct-01 %sclass=memory" % (w.chain, why, SID,
|
|
1032
|
+
"holder=6060 " if why == "holder"
|
|
1033
|
+
else ""), self.log_lines()[2])
|
|
1034
|
+
self.assertIsNone(w.memory)
|
|
1035
|
+
# the pane read by the scan and once more right before the stop (the directory
|
|
1036
|
+
# is looked for before the probe: the scan alone), and never in the backoff
|
|
1037
|
+
self.assertEqual(len(r.captures), 1 if why == "cwd" else 2)
|
|
1038
|
+
self.assertEqual(len(r.probes), 1 if why == "cwd" else 2)
|
|
1039
|
+
self.assertEqual(r.signals, [(self.PID, signal.SIGTERM)])
|
|
1040
|
+
kv, argv = self.relaunch_of(r)
|
|
1041
|
+
self.assertEqual((kv["class"], kv["avoid"]), ("quota", "acct-01:%d" % (t + 9000)))
|
|
1042
|
+
self.assertGreaterEqual(self.term_at(r)[0], 100)
|
|
1043
|
+
|
|
1044
|
+
def test_after_a_refusal_the_pane_is_read_again_once_the_backoff_is_over(self):
|
|
1045
|
+
# synchronized panes refuse the stop at +5 and are turned off at +60; with
|
|
1046
|
+
# MEMORY_BACKOFF 120 the pane is read again at +125, and that verdict moves it at +130
|
|
1047
|
+
t = self.t0
|
|
1048
|
+
w, r = self.start(env={"CLAUDE_MULTIACC_AR_MEMORY_BACKOFF": "120"})
|
|
1049
|
+
r.pane_sync = 1
|
|
1050
|
+
r.at(t + 60, lambda: setattr(r, "pane_sync", 0))
|
|
1051
|
+
self.assertEqual(w.run(), 0)
|
|
1052
|
+
self.assertEqual([round(c["t"] - t) for c in r.captures], [0, 5, 125, 130])
|
|
1053
|
+
self.assertEqual(self.events(), ["watch", "detect", "giveup", "detect", "switch"])
|
|
1054
|
+
self.assert_relaunched(r)
|
|
1055
|
+
|
|
1056
|
+
def test_a_refusal_that_lasts_is_logged_once_a_verdict_and_a_limit_still_gives_up(self):
|
|
1057
|
+
# Synchronized panes for good: each memory verdict (+0, +125, +250, +375 with
|
|
1058
|
+
# MEMORY_BACKOFF 120) is probed once and refused once, one line each. A limit refused
|
|
1059
|
+
# the same way still ends the watch, as it always has
|
|
1060
|
+
t = int(self.t0)
|
|
1061
|
+
w, r = self.start(env={"CLAUDE_MULTIACC_AR_MEMORY_BACKOFF": "120"})
|
|
1062
|
+
r.pane_sync = 1
|
|
1063
|
+
tpath = self.proj / (SID + ".jsonl")
|
|
1064
|
+
r.at(t + 400, lambda: write(tpath, [cc_quota(t + 400, resets=t + 9000)]))
|
|
1065
|
+
self.assertEqual(w.run(), 0)
|
|
1066
|
+
self.assertEqual(self.events(), ["watch"] + ["detect", "giveup"] * 5)
|
|
1067
|
+
refused = [line for line in self.log_lines() if " reason=memory-sync " in line]
|
|
1068
|
+
self.assertEqual(len(refused), 4)
|
|
1069
|
+
self.assertEqual([round(p["t"] - t) for p in r.probes], [5, 130, 255, 380, 405])
|
|
1070
|
+
self.assertIn(" giveup provider=claude chain=%s reason=sync sid=%s class=quota"
|
|
1071
|
+
% (w.chain, SID), self.log_lines()[-1])
|
|
1072
|
+
self.assertEqual((r.signals, r.sends, self.relaunch_files()), ([], [], []))
|
|
1073
|
+
|
|
1074
|
+
def test_the_scan_is_paced_and_can_be_turned_off(self):
|
|
1075
|
+
w, r = self.start(env={"CLAUDE_MULTIACC_AR_MEMORY_SCAN": "10"})
|
|
1076
|
+
r.screen = screen(None)
|
|
1077
|
+
self.stop_at(r, self.t0 + 95)
|
|
1078
|
+
self.assertEqual(w.run(), 0)
|
|
1079
|
+
self.assertEqual([round(c["t"] - self.t0) for c in r.captures], list(range(0, 91, 10)))
|
|
1080
|
+
w, r = self.start(env={"CLAUDE_MULTIACC_AR_MEMORY_SCAN": "0"})
|
|
1081
|
+
self.stop_at(r, self.t0 + 95)
|
|
1082
|
+
self.assertEqual(w.run(), 0)
|
|
1083
|
+
self.assert_untouched(r)
|
|
1084
|
+
self.assertEqual(r.captures, [])
|
|
1085
|
+
|
|
1086
|
+
def test_the_process_backstop_is_opt_in(self):
|
|
1087
|
+
gib = 1 << 30
|
|
1088
|
+
w, r = self.start()
|
|
1089
|
+
r.screen, r.used = screen(None), 13 * gib
|
|
1090
|
+
self.stop_at(r, self.t0 + 100)
|
|
1091
|
+
self.assertEqual(w.run(), 0)
|
|
1092
|
+
self.assert_untouched(r)
|
|
1093
|
+
self.assertEqual(r.footprints, 0) # off by default: never even read
|
|
1094
|
+
w, r = self.start(env={"CLAUDE_MULTIACC_AR_MEMORY_LIMIT_MB": "12000"})
|
|
1095
|
+
r.screen, r.used = screen(None), 13 * gib
|
|
1096
|
+
self.assertEqual(w.run(), 0)
|
|
1097
|
+
self.assert_relaunched(r, size="13GB")
|
|
1098
|
+
self.assertIn(" code=memory_limit sid=%s acct=acct-01 reset=- size=13GB src=process" % SID,
|
|
1099
|
+
self.log_lines()[1])
|
|
1100
|
+
# under the limit, or unreadable: nothing
|
|
1101
|
+
for used in (11 * gib, None):
|
|
1102
|
+
w, r = self.start(env={"CLAUDE_MULTIACC_AR_MEMORY_LIMIT_MB": "12000"})
|
|
1103
|
+
r.screen, r.used = screen(None), used
|
|
1104
|
+
self.stop_at(r, self.t0 + 60)
|
|
1105
|
+
self.assertEqual(w.run(), 0)
|
|
1106
|
+
self.assert_untouched(r)
|
|
1107
|
+
|
|
1108
|
+
def test_codex_never_reads_its_pane(self):
|
|
1109
|
+
# a codex session past MEMORY_MIN_UPTIME, its rollout found, the backstop on: the
|
|
1110
|
+
# client is not claude, and neither its pane nor its memory is ever read
|
|
1111
|
+
for acct in ("acct-01", "acct-02"):
|
|
1112
|
+
os.rmdir(str(self.root / acct / "sessions"))
|
|
1113
|
+
os.symlink(str(self.shared / "sessions"), str(self.root / acct / "sessions"))
|
|
1114
|
+
day = self.shared / "sessions" / "2026" / "10" / "20"
|
|
1115
|
+
day.mkdir(parents=True)
|
|
1116
|
+
(day / ("rollout-2026-10-20T08-00-00-%s.jsonl" % CX_SID)).write_text(
|
|
1117
|
+
jl([cx_meta(self.launched + 5, cwd=str(self.work))]))
|
|
1118
|
+
state = self.write_state(["--yolo", "resume", CX_SID], provider="codex")
|
|
1119
|
+
w, r = self.watcher(state, {"CODEX_MULTIACC_AR_MEMORY_LIMIT_MB": "1"},
|
|
1120
|
+
MemoryRunner(self.t0, self.PID, self.PPID, self.self_path))
|
|
1121
|
+
r.used = 1 << 34
|
|
1122
|
+
r.at(self.t0 + 70, lambda: r.exit(self.PID))
|
|
1123
|
+
self.assertEqual(w.run(), 0)
|
|
1124
|
+
self.assertEqual(w.sid, CX_SID)
|
|
1125
|
+
self.assertEqual((r.captures, r.footprints, r.probes), ([], 0, []))
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
class FootprintTests(unittest.TestCase):
|
|
1129
|
+
@unittest.skipUnless(sys.platform == "darwin" or os.path.isdir("/proc/self"),
|
|
1130
|
+
"no footprint source on this platform")
|
|
1131
|
+
def test_reads_this_process_and_nothing_for_a_pid_that_is_not_there(self):
|
|
1132
|
+
runner = ar.Runner()
|
|
1133
|
+
used = runner.footprint(os.getpid())
|
|
1134
|
+
self.assertIsNotNone(used)
|
|
1135
|
+
self.assertGreater(used, 1 << 20)
|
|
1136
|
+
self.assertLess(used, 1 << 40)
|
|
1137
|
+
self.assertIsNone(runner.footprint(4999999))
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
if __name__ == "__main__":
|
|
1141
|
+
unittest.main()
|