phern 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {phern-0.1.0 → phern-0.2.0}/PKG-INFO +10 -9
- {phern-0.1.0 → phern-0.2.0}/pyproject.toml +15 -9
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/_http.py +1 -1
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/_media.py +1 -1
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/anthropic.py +2 -2
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/google.py +3 -3
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/openai_compatible.py +2 -2
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/agents.py +8 -25
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/cli.py +167 -14
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/console.py +4 -4
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/cancelsafe.py +10 -10
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/client.py +2 -2
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/duplex.py +4 -4
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/frontend.py +2 -2
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/launch.py +1 -1
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/projections.py +3 -3
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/recovery.py +0 -22
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/server.py +296 -43
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/supervisor.py +144 -27
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/modes/transcript_mode.py +1 -1
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/params.py +23 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/payloads.py +107 -8
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles.py +2 -2
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/protocol.py +30 -5
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/runtime.py +1 -1
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/sessions.py +62 -10
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/shell.py +2 -2
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/adapter.py +7 -7
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/app.py +174 -14
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/commands.py +3 -2
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/config.py +43 -19
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/frontend.py +6 -1
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/base.py +2 -2
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/pickers.py +81 -10
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/remote.py +36 -7
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/screens.py +1 -1
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/state.py +64 -1
- phern-0.2.0/src/ph_app/tui/themes/__init__.py +514 -0
- phern-0.2.0/src/ph_app/tui/themes/catppuccin-frappe.yaml +57 -0
- phern-0.2.0/src/ph_app/tui/themes/catppuccin-latte.yaml +57 -0
- phern-0.2.0/src/ph_app/tui/themes/catppuccin-macchiato.yaml +57 -0
- phern-0.2.0/src/ph_app/tui/themes/catppuccin-mocha.yaml +57 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/trajectory.py +2 -2
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/trajectory_app.py +10 -8
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/widgets/prompt.py +140 -6
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/widgets/status.py +61 -6
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/widgets/transcript.py +2 -2
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/verbs.py +13 -3
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/web/serve.py +4 -4
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/wire.py +1 -1
- phern-0.2.0/tests/__snapshots__/test_tui_snapshot/test_the_full_app_shell.raw +155 -0
- {phern-0.1.0 → phern-0.2.0}/tests/daemon_helpers.py +38 -3
- {phern-0.1.0 → phern-0.2.0}/tests/test_adapters.py +2 -2
- {phern-0.1.0 → phern-0.2.0}/tests/test_agents_cli.py +57 -6
- {phern-0.1.0 → phern-0.2.0}/tests/test_cancelsafe.py +16 -16
- {phern-0.1.0 → phern-0.2.0}/tests/test_cli.py +147 -2
- {phern-0.1.0 → phern-0.2.0}/tests/test_daemon.py +15 -8
- {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_asks.py +1 -1
- {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_invariants.py +1 -1
- phern-0.2.0/tests/test_daemon_lifetime.py +546 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_methods.py +61 -2
- {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_projections.py +2 -2
- {phern-0.1.0 → phern-0.2.0}/tests/test_modes.py +5 -2
- {phern-0.1.0 → phern-0.2.0}/tests/test_non_guarantees.py +2 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_payloads.py +23 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_trajectory.py +4 -4
- {phern-0.1.0 → phern-0.2.0}/tests/test_tui_adapter.py +3 -3
- {phern-0.1.0 → phern-0.2.0}/tests/test_tui_copy.py +1 -1
- {phern-0.1.0 → phern-0.2.0}/tests/test_tui_pilot.py +313 -3
- {phern-0.1.0 → phern-0.2.0}/tests/test_tui_remote.py +168 -4
- {phern-0.1.0 → phern-0.2.0}/tests/test_tui_state.py +115 -70
- phern-0.2.0/tests/test_tui_themes.py +302 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_uploads.py +1 -1
- {phern-0.1.0 → phern-0.2.0}/tests/test_uploads_google.py +2 -2
- {phern-0.1.0 → phern-0.2.0}/tests/test_web.py +3 -3
- {phern-0.1.0 → phern-0.2.0}/tests/test_workspaces_cli.py +8 -8
- phern-0.1.0/src/ph_app/tui/themes/__init__.py +0 -230
- phern-0.1.0/tests/__snapshots__/test_tui_snapshot/test_the_full_app_shell.raw +0 -155
- phern-0.1.0/tests/test_daemon_lifetime.py +0 -283
- {phern-0.1.0 → phern-0.2.0}/.gitignore +0 -0
- {phern-0.1.0 → phern-0.2.0}/LICENSE +0 -0
- {phern-0.1.0 → phern-0.2.0}/README.md +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/__init__.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/__main__.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/__init__.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/sse.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/attach.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/attachments.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/__init__.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/cards.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/follow.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/framing.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/modes/__init__.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/modes/json_mode.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/modes/print_mode.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/modes/rpc_mode.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles/anthropic.yaml +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles/deepseek.yaml +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles/google.yaml +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles/llama.yaml +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles/rlm-stable.yaml +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles/tui.yaml +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/py.typed +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/trust.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/__init__.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/autocomplete.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/__init__.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/approval.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/ask_user.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/login.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/trust.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/terminal.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/themes/high-contrast.json +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/themes/ph-dark.json +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/themes/ph-light.json +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/trajectory_screen.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/widgets/__init__.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/widgets/selection.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/widgets/trajectory.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/web/__init__.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/src/ph_app/workspaces.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_a_code_cell_shows_its_program_and_what_it_cost.raw +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_a_code_mode_card_lists_its_dispatches.raw +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_a_compaction_marker_keeps_what_it_replaced.raw +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_a_failed_tool_card_and_an_error_row.raw +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_a_settled_tool_card.raw +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[high-contrast].raw +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[ph-light].raw +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_markup_in_user_text_renders_literally.raw +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_streaming_assistant_text.raw +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/app_fixtures.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_app_layering.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_attach.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_attachments_cli.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_attachments.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_framing.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_launch.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_mutations.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_shell.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_protocol.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_trajectory_view.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_tui_code_cell.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_tui_frames.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_tui_screens.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_tui_snapshot.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/test_uploads_openai.py +0 -0
- {phern-0.1.0 → phern-0.2.0}/tests/tui_helpers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: phern
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: pH — a Python agent harness with no privileged core: every part is a row in a profile.
|
|
5
5
|
Project-URL: Homepage, https://github.com/chastabor/pH
|
|
6
6
|
Project-URL: Repository, https://github.com/chastabor/pH
|
|
@@ -24,19 +24,20 @@ Requires-Python: >=3.12
|
|
|
24
24
|
Requires-Dist: anyio<5,>=4.4
|
|
25
25
|
Requires-Dist: filelock>=3.15
|
|
26
26
|
Requires-Dist: httpx>=0.27
|
|
27
|
-
Requires-Dist: ph-code-graph==0.
|
|
28
|
-
Requires-Dist: ph-core==0.
|
|
29
|
-
Requires-Dist: ph-rlm==0.
|
|
30
|
-
Requires-Dist: ph-runtime-guest==0.
|
|
31
|
-
Requires-Dist: ph-stabilize==0.
|
|
32
|
-
Requires-Dist: ph-text-index==0.
|
|
27
|
+
Requires-Dist: ph-code-graph==0.2.0
|
|
28
|
+
Requires-Dist: ph-core==0.2.0
|
|
29
|
+
Requires-Dist: ph-rlm==0.2.0
|
|
30
|
+
Requires-Dist: ph-runtime-guest==0.2.0
|
|
31
|
+
Requires-Dist: ph-stabilize==0.2.0
|
|
32
|
+
Requires-Dist: ph-text-index==0.2.0
|
|
33
|
+
Requires-Dist: pyyaml>=6.0
|
|
33
34
|
Requires-Dist: rich>=13.0
|
|
34
35
|
Requires-Dist: textual<9,>=8.2.5
|
|
35
36
|
Requires-Dist: typer>=0.12
|
|
36
37
|
Provides-Extra: local
|
|
37
|
-
Requires-Dist: ph-text-index[local]==0.
|
|
38
|
+
Requires-Dist: ph-text-index[local]==0.2.0; extra == 'local'
|
|
38
39
|
Provides-Extra: otel
|
|
39
|
-
Requires-Dist: ph-core[otel]==0.
|
|
40
|
+
Requires-Dist: ph-core[otel]==0.2.0; extra == 'otel'
|
|
40
41
|
Provides-Extra: web
|
|
41
42
|
Requires-Dist: textual-serve>=1.1.3; extra == 'web'
|
|
42
43
|
Description-Content-Type: text/markdown
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "phern"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "pH — a Python agent harness with no privileged core: every part is a row in a profile."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -45,20 +45,26 @@ dependencies = [
|
|
|
45
45
|
# `ph.bundles` composes a profile on an install of this package, and one
|
|
46
46
|
# uninstalled from an install of this package takes its profiles out of
|
|
47
47
|
# `phern doctor` rather than breaking the six that remain.
|
|
48
|
-
"ph-core==0.
|
|
49
|
-
"ph-rlm==0.
|
|
50
|
-
"ph-stabilize==0.
|
|
51
|
-
"ph-text-index==0.
|
|
52
|
-
"ph-code-graph==0.
|
|
48
|
+
"ph-core==0.2.0",
|
|
49
|
+
"ph-rlm==0.2.0",
|
|
50
|
+
"ph-stabilize==0.2.0",
|
|
51
|
+
"ph-text-index==0.2.0",
|
|
52
|
+
"ph-code-graph==0.2.0",
|
|
53
53
|
# Reached through `ph-rlm` already; pinned here too because `ph_rlm.kernel`
|
|
54
54
|
# installs it *by name* into `$PH_CACHE/runtime-venv`, so the version this
|
|
55
55
|
# deployment resolves is the version a cell runs against.
|
|
56
|
-
"ph-runtime-guest==0.
|
|
56
|
+
"ph-runtime-guest==0.2.0",
|
|
57
57
|
# Declared although `ph-core` already requires it: `ph_app.daemon` imports
|
|
58
58
|
# `anyio` directly at 20-odd sites and `cancelsafe` is keyed to its
|
|
59
59
|
# internals, so arriving here transitively understated the coupling.
|
|
60
60
|
"anyio>=4.4,<5",
|
|
61
61
|
"filelock>=3.15",
|
|
62
|
+
# Declared although `ph-core` already requires it, for `anyio`'s reason above:
|
|
63
|
+
# `ph_app` imports `yaml` directly — `cli.py` to print a composed profile, and
|
|
64
|
+
# `tui.themes` to write `$PH_HOME/themes/theme-profile.yaml` — so arriving here
|
|
65
|
+
# transitively understated the coupling. Reading YAML still goes through
|
|
66
|
+
# ph-core's one safe door (`safe_yaml_load`); this is the writing half.
|
|
67
|
+
"pyyaml>=6.0",
|
|
62
68
|
"typer>=0.12",
|
|
63
69
|
"rich>=13.0",
|
|
64
70
|
"httpx>=0.27",
|
|
@@ -106,13 +112,13 @@ web = ["textual-serve>=1.1.3"]
|
|
|
106
112
|
# default CUDA wheels. Without it `text_index` and `text_search` still register
|
|
107
113
|
# and still search an index you supply vectors for — what refuses to mount is
|
|
108
114
|
# the row that *computes* them, with a message naming this extra.
|
|
109
|
-
local = ["ph-text-index[local]==0.
|
|
115
|
+
local = ["ph-text-index[local]==0.2.0"]
|
|
110
116
|
|
|
111
117
|
# Exporting the session log to an OTel collector, passed through for the same
|
|
112
118
|
# reason `local` is: `ph-core` owns the row and the refusal message, but `phern`
|
|
113
119
|
# is the name a person installs, so all three of this deployment's extras should
|
|
114
120
|
# be reachable without first learning which member implements which.
|
|
115
|
-
otel = ["ph-core[otel]==0.
|
|
121
|
+
otel = ["ph-core[otel]==0.2.0"]
|
|
116
122
|
|
|
117
123
|
[project.entry-points."ph.plugins"]
|
|
118
124
|
llm-openai-compatible = "ph_app.adapters.openai_compatible:apply"
|
|
@@ -40,7 +40,7 @@ def resolve_secret(ctx: Context, env_name: str, provider: str) -> str:
|
|
|
40
40
|
"""Turn a credential *name* into its value — here, at the edge, and nowhere above (I-3).
|
|
41
41
|
|
|
42
42
|
The value goes into a local that goes out of scope with the request. Nothing
|
|
43
|
-
that
|
|
43
|
+
that traveled to get here held it.
|
|
44
44
|
"""
|
|
45
45
|
credentials = ctx.get(CREDENTIALS)
|
|
46
46
|
if credentials is None:
|
|
@@ -125,7 +125,7 @@ async def load_handles(
|
|
|
125
125
|
)
|
|
126
126
|
except Exception:
|
|
127
127
|
# Deliberately broad and deliberately not fatal: an upload is an
|
|
128
|
-
#
|
|
128
|
+
# optimization, and a route that can take the bytes inline must
|
|
129
129
|
# not lose a turn because a file API was down.
|
|
130
130
|
log.warning(
|
|
131
131
|
"ph_app.adapters: could not upload %s to %s; sending it inline",
|
|
@@ -379,7 +379,7 @@ class AnthropicAdapter:
|
|
|
379
379
|
yield chunk
|
|
380
380
|
except LlmError as error:
|
|
381
381
|
# A handle this request referenced is gone — expired early, deleted
|
|
382
|
-
# from another session, or never
|
|
382
|
+
# from another session, or never honored. **Forget it first, then
|
|
383
383
|
# let it retry**: `FILE_EXPIRED` is in `TRANSIENT_CODES` precisely
|
|
384
384
|
# because the state that caused it is already cleared by the time the
|
|
385
385
|
# retry runs, so the next attempt re-uploads rather than repeating a
|
|
@@ -578,7 +578,7 @@ def _to_anthropic(
|
|
|
578
578
|
|
|
579
579
|
`media` holds base64 for the attachments this route will take; anything absent
|
|
580
580
|
from it becomes a pointer. **Nothing is dropped** — a block kind this function
|
|
581
|
-
does not
|
|
581
|
+
does not recognize still reaches the wire, rather than a message that was only an
|
|
582
582
|
image arriving as an empty text block.
|
|
583
583
|
"""
|
|
584
584
|
blocks: list[dict[str, Any]] = []
|
|
@@ -101,7 +101,7 @@ ACCEPTED_MEDIA: tuple[str, ...] = (
|
|
|
101
101
|
"""The default for `Config.accepts` — what this provider's own models take.
|
|
102
102
|
|
|
103
103
|
The widest of the three, and video is the entry that matters: it is the format
|
|
104
|
-
that made upload-and-reference a row rather than an
|
|
104
|
+
that made upload-and-reference a row rather than an optimization, and until this
|
|
105
105
|
adapter existed no route pH shipped could declare it honestly."""
|
|
106
106
|
|
|
107
107
|
UPLOADED_MEDIA: tuple[str, ...] = (
|
|
@@ -257,7 +257,7 @@ class GoogleAdapter:
|
|
|
257
257
|
Self-limiting rather than capped: an entry is written only when the readiness
|
|
258
258
|
budget runs out, and removed the moment the file is usable or turns out to be
|
|
259
259
|
gone — so it holds what this process is still waiting on and nothing else. A
|
|
260
|
-
restart loses it and pays the transfer again, which is exactly the
|
|
260
|
+
restart loses it and pays the transfer again, which is exactly the behavior
|
|
261
261
|
this replaced."""
|
|
262
262
|
|
|
263
263
|
def _headers(self) -> dict[str, str]:
|
|
@@ -291,7 +291,7 @@ class GoogleAdapter:
|
|
|
291
291
|
again. Now the name is remembered and the next attempt polls it.
|
|
292
292
|
|
|
293
293
|
Failure of any kind here is caught by `load_handles` and the attachment
|
|
294
|
-
goes inline — so the worst outcome of a slow provider is the
|
|
294
|
+
goes inline — so the worst outcome of a slow provider is the behavior
|
|
295
295
|
every route had before the row.
|
|
296
296
|
"""
|
|
297
297
|
spent = self._spent.get(ref.attachment_id, 0)
|
|
@@ -139,7 +139,7 @@ class WindowProbe(WireModel):
|
|
|
139
139
|
Measured against a llama.cpp serving Qwen3.8-27B, started `-c 524288
|
|
140
140
|
--parallel 2`: `/props` and both `/slots` entries report 262 144 — the total
|
|
141
141
|
divided, as llama.cpp documents — while `/v1/models` reports `meta.n_ctx`
|
|
142
|
-
262 144 beside `meta.n_ctx_train` 262 144, its
|
|
142
|
+
262 144 beside `meta.n_ctx_train` 262 144, its neighbors there being
|
|
143
143
|
`n_vocab`, `n_embd`, `n_params` and `ftype`.
|
|
144
144
|
|
|
145
145
|
**That server is what settles it.** Its total was 524 288, and the number at
|
|
@@ -392,7 +392,7 @@ class OpenAiCompatibleAdapter:
|
|
|
392
392
|
yield chunk
|
|
393
393
|
except LlmError as error:
|
|
394
394
|
# A handle this request referenced is gone — expired early, deleted
|
|
395
|
-
# from another session, or never
|
|
395
|
+
# from another session, or never honored. Forget it first, then let
|
|
396
396
|
# it retry: `FILE_EXPIRED` is in `TRANSIENT_CODES` precisely because
|
|
397
397
|
# the state that caused it is already cleared by the time the retry
|
|
398
398
|
# runs, so the next attempt re-uploads rather than repeating a request
|
|
@@ -43,6 +43,7 @@ from ph.paths import RuntimeDirError, resolve_roots
|
|
|
43
43
|
from ph.resources import GRACE_SECONDS
|
|
44
44
|
from ph.seams.schedule import ScheduleKind
|
|
45
45
|
from ph.selectors import Selector, matches_any
|
|
46
|
+
from ph.text import duration
|
|
46
47
|
|
|
47
48
|
from . import verbs
|
|
48
49
|
from .console import TypeOption, console, detail, fail, section, selectors_or_exit
|
|
@@ -166,30 +167,12 @@ def _cadence(seconds: float) -> str:
|
|
|
166
167
|
|
|
167
168
|
**All five read `0` as off**, each behind its own `if x > 0` in `serve`, and
|
|
168
169
|
for a day only the newest one said so on screen while the other four printed
|
|
169
|
-
`0s`. `
|
|
170
|
+
`0s`. `duration` cannot absorb the rule: zero milliseconds is a real and
|
|
170
171
|
different answer elsewhere (an uptime, a turn that took no measurable time),
|
|
171
172
|
where "off" would be a lie. So the convention lives here, once, next to the
|
|
172
173
|
only four-plus-one readers that share it.
|
|
173
174
|
"""
|
|
174
|
-
return
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def _duration(milliseconds: object) -> str:
|
|
178
|
-
"""`3d 4h`, `1h 30m`, `12m`, `8s` — the two largest units that are not zero.
|
|
179
|
-
|
|
180
|
-
Zeros are dropped rather than kept for shape: a sweep every sixty seconds
|
|
181
|
-
reads as `1m`, not `1m 0s`, and an hour and a half of quiet is `1h 30m`
|
|
182
|
-
whether or not the seconds happen to be round.
|
|
183
|
-
"""
|
|
184
|
-
if not isinstance(milliseconds, int | float):
|
|
185
|
-
return "—"
|
|
186
|
-
seconds = int(milliseconds // 1000)
|
|
187
|
-
parts: list[str] = []
|
|
188
|
-
for name, size in (("d", 86400), ("h", 3600), ("m", 60), ("s", 1)):
|
|
189
|
-
count, seconds = divmod(seconds, size)
|
|
190
|
-
if count:
|
|
191
|
-
parts.append(f"{count}{name}")
|
|
192
|
-
return " ".join(parts[:2]) or "0s"
|
|
175
|
+
return duration(seconds * 1000) if seconds > 0 else "off"
|
|
193
176
|
|
|
194
177
|
|
|
195
178
|
def _summary(kind: str, event: JsonObject) -> str:
|
|
@@ -466,7 +449,7 @@ def attach(
|
|
|
466
449
|
Idle is how a root that answered and a root whose last answer failed both
|
|
467
450
|
look, so a script chaining on this had no way to tell them apart and read the
|
|
468
451
|
second as success (P5-04). Only `error` — the kind the loop records when a
|
|
469
|
-
turn could not be completed. A turn a person
|
|
452
|
+
turn could not be completed. A turn a person canceled, or one a limit ended,
|
|
470
453
|
exits 0 and says so on its own line: those are outcomes somebody already
|
|
471
454
|
knows about, and turning them into a failed command would make the ordinary
|
|
472
455
|
way of stopping an agent look like a fault.
|
|
@@ -566,9 +549,9 @@ def schedule(
|
|
|
566
549
|
CancelScheduleParams(session_id=session, schedule_id=cancel),
|
|
567
550
|
)
|
|
568
551
|
)
|
|
569
|
-
if not outcome.
|
|
552
|
+
if not outcome.canceled:
|
|
570
553
|
fail(f"[red]no schedule {cancel!r} on {session}[/red]")
|
|
571
|
-
console.print(f"[dim]
|
|
554
|
+
console.print(f"[dim]canceled {cancel}[/dim]")
|
|
572
555
|
return
|
|
573
556
|
|
|
574
557
|
if len(timings) > 1:
|
|
@@ -666,12 +649,12 @@ def doctor() -> None:
|
|
|
666
649
|
(
|
|
667
650
|
("socket", facts.socket),
|
|
668
651
|
("pid", str(facts.pid)),
|
|
669
|
-
("uptime",
|
|
652
|
+
("uptime", duration(facts.uptime_ms)),
|
|
670
653
|
("protocol", str(facts.protocol_version)),
|
|
671
654
|
("capabilities", ", ".join(sorted(facts.capabilities))),
|
|
672
655
|
("roots", str(facts.roots)),
|
|
673
656
|
("provider", f"{facts.provider} · {facts.model}"),
|
|
674
|
-
("passivate after", "off" if passivate is None else
|
|
657
|
+
("passivate after", "off" if passivate is None else duration(passivate * 1000)),
|
|
675
658
|
("tick", _cadence(facts.tick_every)),
|
|
676
659
|
("sweep", _cadence(facts.sweep_every)),
|
|
677
660
|
("heartbeat", _cadence(facts.heartbeat_every)),
|
|
@@ -25,7 +25,7 @@ from __future__ import annotations
|
|
|
25
25
|
import json
|
|
26
26
|
import shlex
|
|
27
27
|
import sys
|
|
28
|
-
from collections.abc import Awaitable, Callable, Sequence
|
|
28
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
29
29
|
from functools import partial
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
from typing import Annotated, Any, Literal, TypeAlias
|
|
@@ -146,6 +146,20 @@ def default(
|
|
|
146
146
|
resume: Annotated[
|
|
147
147
|
str | None, typer.Option("--resume", help="Session id to reopen (tui and web).")
|
|
148
148
|
] = None,
|
|
149
|
+
keep_alive: Annotated[
|
|
150
|
+
str | None,
|
|
151
|
+
typer.Option(
|
|
152
|
+
"--keep-alive",
|
|
153
|
+
help='Keep a daemon this UI starts up for this long after it detaches ("5m").',
|
|
154
|
+
),
|
|
155
|
+
] = None,
|
|
156
|
+
new: Annotated[
|
|
157
|
+
bool,
|
|
158
|
+
typer.Option(
|
|
159
|
+
"--new",
|
|
160
|
+
help="Start a fresh session without offering this directory's previous ones (tui).",
|
|
161
|
+
),
|
|
162
|
+
] = False,
|
|
149
163
|
no_spawn: Annotated[
|
|
150
164
|
bool,
|
|
151
165
|
typer.Option(
|
|
@@ -224,9 +238,14 @@ def default(
|
|
|
224
238
|
model=model,
|
|
225
239
|
patch=patch,
|
|
226
240
|
keep=keep_daemon,
|
|
241
|
+
keep_alive=_spawned_keep_alive(keep_alive, keep=keep_daemon),
|
|
227
242
|
),
|
|
228
243
|
session_id=wanted,
|
|
229
244
|
spawn=not no_spawn,
|
|
245
|
+
# `--session`/`--resume` already name one, so they skip the offer
|
|
246
|
+
# by having an answer; `--new` is for the person who never wants
|
|
247
|
+
# to be asked.
|
|
248
|
+
offer_sessions=not new,
|
|
230
249
|
)
|
|
231
250
|
)
|
|
232
251
|
return
|
|
@@ -449,24 +468,124 @@ always passes its own value.
|
|
|
449
468
|
"""
|
|
450
469
|
|
|
451
470
|
|
|
471
|
+
def _spawned_keep_alive(value: str | None, *, keep: bool) -> str:
|
|
472
|
+
"""The `--keep-alive` a spawned daemon is given, in seconds, refused here on a typo.
|
|
473
|
+
|
|
474
|
+
**Parsed at this end, not forwarded as typed.** `phern --keep-alive` does not run
|
|
475
|
+
a daemon, it composes the argv for one — so a string handed straight through
|
|
476
|
+
would be refused by a process whose output goes to the null device, and the
|
|
477
|
+
person would see a UI that could not reach a daemon and no reason why. Same
|
|
478
|
+
parser, same message, whichever end they typed it at; what crosses is a bare
|
|
479
|
+
number of seconds, which `keep_alive_seconds` reads back unambiguously.
|
|
480
|
+
|
|
481
|
+
CLI first, then `tui.json`, then zero: a person who sets it once should not
|
|
482
|
+
have to type it, and a person who typed it means this run. The file is read
|
|
483
|
+
only when they did not, which is also why it is not hoisted — settings are
|
|
484
|
+
deliberately re-read for each `PHTuiApp`, so that a `/theme` written during
|
|
485
|
+
a session is picked up when the picker reopens one.
|
|
486
|
+
|
|
487
|
+
**`--keep-daemon` and a typed `--keep-alive` are refused together**, because
|
|
488
|
+
they ask for opposite things: one says the daemon stays, the other says how
|
|
489
|
+
long it waits before going. A warning would have been the wrong answer to a
|
|
490
|
+
person who has said two things and meant one of them. A *configured*
|
|
491
|
+
keep-alive is not a contradiction — nobody typed it for this run — so
|
|
492
|
+
`--keep-daemon` simply overrides it, and a service daemon has no window.
|
|
493
|
+
|
|
494
|
+
`ph_app.tui.config` is imported inside the function for `run_tui`'s reason:
|
|
495
|
+
it is a front-end module and `phern -p` should not load one to print an
|
|
496
|
+
answer.
|
|
497
|
+
"""
|
|
498
|
+
if keep:
|
|
499
|
+
if value is not None and keep_alive_seconds(value) > 0:
|
|
500
|
+
raise typer.BadParameter(
|
|
501
|
+
"--keep-alive and --keep-daemon ask for opposite things: one says when "
|
|
502
|
+
"the daemon leaves, the other says it stays"
|
|
503
|
+
)
|
|
504
|
+
return "0"
|
|
505
|
+
if value is None:
|
|
506
|
+
from .tui.config import load_tui_settings
|
|
507
|
+
|
|
508
|
+
value = load_tui_settings(resolve_roots().home).daemon_keep_alive
|
|
509
|
+
return f"{keep_alive_seconds(value):g}"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
UNITS: Mapping[str, float] = {"s": 1.0, "m": 60.0, "h": 3600.0}
|
|
513
|
+
"""The suffixes a duration option accepts, as seconds each."""
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _duration_seconds(value: str, *, unit: float) -> float:
|
|
517
|
+
"""A duration a person typed, as seconds. Negative for "not one".
|
|
518
|
+
|
|
519
|
+
One parser for `--keep-alive` and `--passivate-after`, which are the same
|
|
520
|
+
question asked twice and were two parsers with two refusal shapes. `unit` is
|
|
521
|
+
what a *bare* number means — seconds for a keep-alive, minutes for a passivation
|
|
522
|
+
window, which is what each flag's own history established — and a suffix
|
|
523
|
+
overrides it, so `--passivate-after 30s` now means what it reads like
|
|
524
|
+
instead of half an hour.
|
|
525
|
+
|
|
526
|
+
Negative rather than raising, because the two callers refuse differently:
|
|
527
|
+
one of them has an `"off"` spelling to check first, and a parser that raised
|
|
528
|
+
would have to know about it.
|
|
529
|
+
"""
|
|
530
|
+
text = value.strip().lower()
|
|
531
|
+
if not text:
|
|
532
|
+
return -1.0
|
|
533
|
+
scale = UNITS.get(text[-1])
|
|
534
|
+
number = text[:-1] if scale is not None else text
|
|
535
|
+
try:
|
|
536
|
+
seconds = float(number) * (scale if scale is not None else unit)
|
|
537
|
+
except ValueError:
|
|
538
|
+
return -1.0
|
|
539
|
+
return seconds if seconds >= 0 else -1.0
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def keep_alive_seconds(value: str) -> float:
|
|
543
|
+
"""`"30s"`, `"5m"`, `"1h"` or a bare number of seconds, as seconds.
|
|
544
|
+
|
|
545
|
+
Refused rather than defaulted on a typo, for `_passivation`'s reason: a
|
|
546
|
+
mistyped duration here is a daemon that leaves immediately or never, and
|
|
547
|
+
either is a surprise a person attributes to something else a week later.
|
|
548
|
+
|
|
549
|
+
**Public, because both ends of the flag have to agree.** `phern daemon
|
|
550
|
+
--keep-alive` parses it here, and `phern --keep-alive` — which does not run the
|
|
551
|
+
daemon, it *spawns* one — parses it here too rather than forwarding the
|
|
552
|
+
string into an argv where a typo would surface as a daemon that refused to
|
|
553
|
+
start and a UI that said nothing. Same refusal, same message, at whichever
|
|
554
|
+
end the person typed it.
|
|
555
|
+
|
|
556
|
+
**A duration and nothing else.** Whether the daemon is ephemeral is the
|
|
557
|
+
caller's question, not this one's: `phern daemon` and `phern` express that
|
|
558
|
+
in opposite directions — one opts into leaving, the other opts into staying
|
|
559
|
+
— so a parser that also decided it would have to know which command it was
|
|
560
|
+
serving.
|
|
561
|
+
"""
|
|
562
|
+
if not value.strip() or value.strip() == "0":
|
|
563
|
+
return 0.0
|
|
564
|
+
seconds = _duration_seconds(value, unit=1.0)
|
|
565
|
+
if seconds < 0:
|
|
566
|
+
# `typer.BadParameter` like `_passivation` and `_children_cap` beside it,
|
|
567
|
+
# rather than a hand-built refusal: typer already writes the usage line
|
|
568
|
+
# and the exit code, and three value parsers on one command refusing in
|
|
569
|
+
# two different shapes is how one of them comes to say something else.
|
|
570
|
+
raise typer.BadParameter(f'wants a duration like "30s", "5m" or "90", not "{value}"')
|
|
571
|
+
return seconds
|
|
572
|
+
|
|
573
|
+
|
|
452
574
|
def _passivation(value: str) -> float | None:
|
|
453
|
-
"""`"off"
|
|
575
|
+
"""`"off"`, a number of minutes, or a suffixed duration, as seconds (P5-05).
|
|
454
576
|
|
|
455
|
-
Refused rather than defaulted when it is
|
|
456
|
-
deployment that silently keeps every root it ever started, and the
|
|
457
|
-
the one process where that goes unnoticed for a week.
|
|
577
|
+
Refused rather than defaulted when it is none of those: a typo in a duration
|
|
578
|
+
is a deployment that silently keeps every root it ever started, and the
|
|
579
|
+
daemon is the one process where that goes unnoticed for a week.
|
|
458
580
|
"""
|
|
459
581
|
if value.strip().lower() == "off":
|
|
460
582
|
return None
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
except ValueError:
|
|
464
|
-
minutes = 0.0
|
|
465
|
-
if minutes <= 0:
|
|
583
|
+
seconds = _duration_seconds(value, unit=60.0)
|
|
584
|
+
if seconds <= 0:
|
|
466
585
|
# One message for one mistake: "not a number" and "not a positive
|
|
467
586
|
# number" are the same correction to the same flag.
|
|
468
587
|
raise typer.BadParameter(f'wants positive minutes or "off", not "{value}"')
|
|
469
|
-
return
|
|
588
|
+
return seconds
|
|
470
589
|
|
|
471
590
|
|
|
472
591
|
def _children_cap(value: int | None) -> list[str]:
|
|
@@ -517,6 +636,13 @@ def daemon(
|
|
|
517
636
|
"--passivate-after", help='Minutes of quiet before a root is released, or "off".'
|
|
518
637
|
),
|
|
519
638
|
] = _DEFAULT_PASSIVATION,
|
|
639
|
+
keep_alive: Annotated[
|
|
640
|
+
str,
|
|
641
|
+
typer.Option(
|
|
642
|
+
"--keep-alive",
|
|
643
|
+
help='Stay up this long after the last client leaves ("5m"); implies --ephemeral.',
|
|
644
|
+
),
|
|
645
|
+
] = "0",
|
|
520
646
|
ephemeral: Annotated[
|
|
521
647
|
bool,
|
|
522
648
|
typer.Option("--ephemeral", help="Exit once no client, root or appointment needs this."),
|
|
@@ -537,6 +663,9 @@ def daemon(
|
|
|
537
663
|
"""
|
|
538
664
|
from .daemon.server import DaemonUnavailable, serve
|
|
539
665
|
|
|
666
|
+
# Parsed before anything is composed or bound, so a mistyped duration is a
|
|
667
|
+
# usage error rather than a daemon that got as far as printing a socket path.
|
|
668
|
+
window = keep_alive_seconds(keep_alive)
|
|
540
669
|
composed = profile_or_exit(profile, _children_cap(max_concurrent_children))
|
|
541
670
|
try:
|
|
542
671
|
roots = resolve_roots(create=True)
|
|
@@ -566,7 +695,14 @@ def daemon(
|
|
|
566
695
|
passivate_after=_passivation(passivate_after),
|
|
567
696
|
# Off here, on in `spawn_command`: `DaemonServer.ephemeral` says
|
|
568
697
|
# why the lifetime is decided by who started it (P7-08).
|
|
569
|
-
|
|
698
|
+
#
|
|
699
|
+
# **A keep-alive implies it.** "Stay up five minutes after the
|
|
700
|
+
# last client leaves" is a statement about *leaving*, so a person
|
|
701
|
+
# who typed one has said which lifetime they want; the two flags
|
|
702
|
+
# were one concept split across two spellings, and the split is
|
|
703
|
+
# what made `--keep-alive` alone mean nothing and need a warning.
|
|
704
|
+
ephemeral=ephemeral or window > 0,
|
|
705
|
+
keep_alive=window,
|
|
570
706
|
path=socket_path,
|
|
571
707
|
)
|
|
572
708
|
)
|
|
@@ -654,7 +790,13 @@ def tab_command(
|
|
|
654
790
|
|
|
655
791
|
|
|
656
792
|
def spawn_command(
|
|
657
|
-
*,
|
|
793
|
+
*,
|
|
794
|
+
profile: str,
|
|
795
|
+
provider: str,
|
|
796
|
+
model: str,
|
|
797
|
+
patch: Sequence[str] = (),
|
|
798
|
+
keep: bool = False,
|
|
799
|
+
keep_alive: str = "0",
|
|
658
800
|
) -> list[str]:
|
|
659
801
|
"""The argv for a daemon a UI starts on its own behalf (P7-08).
|
|
660
802
|
|
|
@@ -669,8 +811,19 @@ def spawn_command(
|
|
|
669
811
|
a decision rather than a property of the process that happened to spawn it.
|
|
670
812
|
"""
|
|
671
813
|
ephemeral = () if keep else ("--ephemeral",)
|
|
814
|
+
# **The only route a client's preference has to the daemon.** A daemon may not
|
|
815
|
+
# read a front end's `tui.json` — after P5-14 it may not share a filesystem
|
|
816
|
+
# with it — so the terminal spells its own keep-alive into the command line it
|
|
817
|
+
# composes. Omitted when zero, which is the default on both sides.
|
|
818
|
+
window = ("--keep-alive", keep_alive) if keep_alive not in ("", "0") else ()
|
|
672
819
|
return reinvoke(
|
|
673
|
-
"daemon",
|
|
820
|
+
"daemon",
|
|
821
|
+
*ephemeral,
|
|
822
|
+
*window,
|
|
823
|
+
profile=profile,
|
|
824
|
+
provider=provider,
|
|
825
|
+
model=model,
|
|
826
|
+
patch=patch,
|
|
674
827
|
)
|
|
675
828
|
|
|
676
829
|
|
|
@@ -10,7 +10,7 @@ and P5-11's socket lifetime. Every one of them is a title and a list of pairs,
|
|
|
10
10
|
and they were already drifting apart in style.
|
|
11
11
|
|
|
12
12
|
**Data does not go through a console.** `--dump-config` writes YAML and `ph
|
|
13
|
-
events --json` writes JSON, both meant to be piped; Rich
|
|
13
|
+
events --json` writes JSON, both meant to be piped; Rich colorizes a plain
|
|
14
14
|
string it is asked to print, so with `FORCE_COLOR` set in the environment — as
|
|
15
15
|
CI images and many shells do — those two commands emitted ANSI escapes into
|
|
16
16
|
their own machine-readable output and `yaml.safe_load` refused it with
|
|
@@ -50,9 +50,9 @@ err = Console(stderr=True, highlight=False, soft_wrap=True)
|
|
|
50
50
|
declared here rather than per module: a console setting applied to half the CLI
|
|
51
51
|
is how the two halves come to disagree about what a line looks like.
|
|
52
52
|
|
|
53
|
-
`highlight=False`: Rich's automatic highlighter re-
|
|
53
|
+
`highlight=False`: Rich's automatic highlighter re-colors whatever in a plain
|
|
54
54
|
string *looks* like a number, a path or a UUID, which in a CLI's prose is an
|
|
55
|
-
arbitrary word
|
|
55
|
+
arbitrary word colored for looking like data — `scheduled sch-1 · interval
|
|
56
56
|
3600000` came out with the interval in cyan and nothing else. Explicit markup
|
|
57
57
|
still works; only the guessing is off. It is also a third of the cost of
|
|
58
58
|
printing a line, and `phern agents attach` prints one per log event.
|
|
@@ -96,7 +96,7 @@ def section(title: str, rows: Iterable[tuple[str, str]]) -> Table:
|
|
|
96
96
|
def emit(text: str) -> None:
|
|
97
97
|
"""Write machine-readable output, unstyled and unwrapped.
|
|
98
98
|
|
|
99
|
-
`print` rather than `console.print`: a console decides
|
|
99
|
+
`print` rather than `console.print`: a console decides color from the
|
|
100
100
|
environment and width from the terminal, and both are wrong for a document
|
|
101
101
|
another program parses. The one thing this must never do is be clever.
|
|
102
102
|
"""
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Make a **
|
|
1
|
+
"""Make a **canceled** socket readiness wait safe, on every platform (issue 58).
|
|
2
2
|
|
|
3
3
|
anyio has two shapes for waiting on a socket. The hardened one, which
|
|
4
4
|
`AsyncIOBackend.wait_readable` uses and `ph_rlm.kernel.manager` reaches through
|
|
@@ -20,7 +20,7 @@ registers the bound `future.set_result` itself and has no such guard:
|
|
|
20
20
|
await f
|
|
21
21
|
|
|
22
22
|
Cancel that wait after the loop has already queued the ready handle and
|
|
23
|
-
`set_result` runs on a
|
|
23
|
+
`set_result` runs on a canceled future, raising `InvalidStateError` from a bare
|
|
24
24
|
loop handle with **no frames of ours on the stack** — which is why the captured
|
|
25
25
|
traceback was one line of `asyncio/events.py` and nothing else. The removal
|
|
26
26
|
cannot close the window: done-callbacks are `call_soon`'d, so they run an
|
|
@@ -33,7 +33,7 @@ deregister the fd: the done-callback above already does, exactly once, and the
|
|
|
33
33
|
hardened shape guards its own removal behind a bookkeeping dict for the same
|
|
34
34
|
reason. A guard that removed the fd itself would make anyio's removal a miss —
|
|
35
35
|
`selectors` raises `KeyError` twice, nested, with a `repr(socket)` that costs
|
|
36
|
-
two more syscalls, on every wait rather than only
|
|
36
|
+
two more syscalls, on every wait rather than only canceled ones.
|
|
37
37
|
|
|
38
38
|
**This is not a bug in pH.** No package here uses `call_soon`, `call_later`,
|
|
39
39
|
`add_reader` or `add_writer`, and pH's one `future.set_result`
|
|
@@ -57,7 +57,7 @@ rather than covered: its default `ProactorEventLoop` has no `add_reader`, and
|
|
|
57
57
|
`AF_UNIX` is not the daemon's transport there.
|
|
58
58
|
|
|
59
59
|
**Interim, with a shelf life.** `test_cancelsafe` fails when anyio stops
|
|
60
|
-
registering the shape this
|
|
60
|
+
registering the shape this recognizes, which is how we would learn the fix had
|
|
61
61
|
landed upstream and this module could go.
|
|
62
62
|
|
|
63
63
|
@module ph_app.daemon.cancelsafe
|
|
@@ -84,8 +84,8 @@ def resolves_a_future(callback: object) -> bool:
|
|
|
84
84
|
Deliberately a *shape* test and not an identity one. `set_result` on the C
|
|
85
85
|
accelerator is a `builtin_method` with no `__func__`, and a `Future`
|
|
86
86
|
subclass may override it — both of which an identity comparison stops
|
|
87
|
-
|
|
88
|
-
always to today's
|
|
87
|
+
recognizing. Anything unrecognized is passed through, so the failure is
|
|
88
|
+
always to today's behavior rather than to a changed one.
|
|
89
89
|
"""
|
|
90
90
|
return isinstance(getattr(callback, "__self__", None), asyncio.Future) and (
|
|
91
91
|
getattr(callback, "__name__", "") == "set_result"
|
|
@@ -119,19 +119,19 @@ def _guarding(original: Callable[..., Any]) -> Callable[..., Any]:
|
|
|
119
119
|
# a `try` and +53 ns as `if not future.done()` — because the
|
|
120
120
|
# exception is only paid on the firing that races, while the check
|
|
121
121
|
# is paid on every readiness event. The check wins only if about
|
|
122
|
-
# one wait in three is
|
|
122
|
+
# one wait in three is canceled inside the window; the observed
|
|
123
123
|
# rate is nearer two per thirty full-suite runs. anyio's own
|
|
124
124
|
# hardened `wait_readable` catches for the same reason.
|
|
125
125
|
#
|
|
126
|
-
# A pre-check would also have to ask `done()`, not `
|
|
126
|
+
# A pre-check would also have to ask `done()`, not `canceled()`:
|
|
127
127
|
# `set_result` refuses two states, and the second — an *already
|
|
128
128
|
# resolved* future, fired on again before anyio's done-callback
|
|
129
|
-
# deregisters the fd — reports `
|
|
129
|
+
# deregisters the fd — reports `canceled() is False`. It is the
|
|
130
130
|
# rarer of the two here (measured: 1 firing under anyio's shape)
|
|
131
131
|
# but the unbounded one if a deregistration is ever missed.
|
|
132
132
|
#
|
|
133
133
|
# `try`/`except` and not `contextlib.suppress`, same reasoning one
|
|
134
|
-
# level down: 5.7 ns against 229 ns for identical
|
|
134
|
+
# level down: 5.7 ns against 229 ns for identical behavior, on the
|
|
135
135
|
# hottest path the daemon has.
|
|
136
136
|
try: # noqa: SIM105
|
|
137
137
|
callback(*args)
|
|
@@ -147,7 +147,7 @@ class DaemonClient:
|
|
|
147
147
|
|
|
148
148
|
`RootDescription` and not `RootDescription | MutationRepeated`, which is
|
|
149
149
|
what this said: `MutationRepeated` subclasses `RootDescription`, so that
|
|
150
|
-
union
|
|
150
|
+
union normalizes to the left side and asked every caller to narrow
|
|
151
151
|
something the checker had already flattened. The repeat still arrives
|
|
152
152
|
here — it is simply already the type it claims to be, which is the
|
|
153
153
|
property `MutationRepeated`'s own docstring is about.
|
|
@@ -253,7 +253,7 @@ async def connected[T](path: Path, work: Exchange[T]) -> T:
|
|
|
253
253
|
replies and notifications arrive on the same stream: a caller that read its
|
|
254
254
|
own reply directly would consume a `session.event` it had no way to hand
|
|
255
255
|
back. Closing the stream is what ends the pump, so there is no cancel here —
|
|
256
|
-
a teardown that
|
|
256
|
+
a teardown that canceled would race the last frame it asked for.
|
|
257
257
|
|
|
258
258
|
Here rather than in `phern agents`, because a one-shot exchange is not a CLI
|
|
259
259
|
shape: `ph_app.web.serve` stages a browser's upload this way too, and the
|