carthagent 1.0.7 → 1.1.0-beta.1
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 +140 -83
- package/bin/ctg.mjs +14 -732
- package/docs/beta-rewards-e2e-plan.md +48 -0
- package/docs/beta-rewards-e2e-results.md +84 -0
- package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
- package/docs/glyph-review/test-isolation-v1.md +26 -0
- package/docs/glyph-review/typecheck-base.log +1 -0
- package/docs/glyph-review/typecheck-v4.log +1 -0
- package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
- package/docs/help-dispatch-review.md +69 -0
- package/docs/launch-readiness-live-results.md +65 -0
- package/docs/legacy-cli.md +150 -0
- package/docs/live-theme-plugin-api.md +18 -0
- package/docs/native-cli.md +102 -0
- package/docs/native-compatibility.md +16 -0
- package/docs/native-daily-quests.md +132 -0
- package/docs/native-glyph-pty.md +59 -0
- package/docs/native-manual-testing.md +336 -0
- package/docs/native-slash-acceptance-handoff.md +49 -0
- package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
- package/docs/native-terminal-performance.md +310 -0
- package/docs/native-terminal-semantics-evidence/README.md +25 -0
- package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
- package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
- package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
- package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
- package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
- package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
- package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
- package/docs/native-terminal-semantics.md +89 -0
- package/docs/native-upgrade.md +175 -0
- package/docs/opencode-composed-source-review.md +89 -0
- package/docs/opencode-migration.md +225 -9
- package/docs/opencode-owned-runtime.md +241 -0
- package/docs/opencode-runtime.md +12 -0
- package/docs/opencode-theme-provider-proof/README.md +55 -0
- package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
- package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
- package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
- package/docs/owned-host-controls-proof.md +39 -0
- package/docs/owned-runtime-release-manifest.md +42 -0
- package/docs/owned-visual-clear-proof.md +40 -0
- package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
- package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
- package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
- package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
- package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
- package/docs/release-notes-beta.1.md +60 -0
- package/docs/release-notes-native-draft.md +73 -0
- package/docs/release-notes-preview.10.md +121 -0
- package/docs/release-notes-preview.11.md +76 -0
- package/docs/release-notes-preview.12.md +55 -0
- package/docs/release-notes-preview.13.md +68 -0
- package/docs/release-notes-preview.9.md +130 -0
- package/docs/source-preview.md +120 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery-review.mjs +19 -6
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1171 -0
- package/lib/opencode/cli.mjs +252 -0
- package/lib/opencode/credentials.mjs +63 -8
- package/lib/opencode/demo.mjs +53 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
- package/lib/opencode/evaluation.mjs +32 -12
- package/lib/opencode/headless.mjs +22 -5
- package/lib/opencode/provider-policy.mjs +23 -0
- package/lib/opencode/review.mjs +37 -16
- package/lib/opencode/runtime-artifact.mjs +335 -0
- package/lib/opencode/runtime-github-transport.mjs +121 -0
- package/lib/opencode/runtime-release.json +36 -0
- package/lib/opencode/runtime.mjs +299 -20
- package/lib/opencode/service.mjs +77 -35
- package/lib/opencode/session-options.mjs +6 -2
- package/lib/opencode/update.mjs +222 -0
- package/lib/opencode/workspace.mjs +1 -1
- package/lib/planning-advice.mjs +2 -2
- package/lib/providers/experiential.mjs +2 -2
- package/lib/tui/framing.mjs +1 -80
- package/lib/update.mjs +10 -1
- package/package.json +33 -7
- package/plugins/carthagent/README.md +23 -0
- package/plugins/carthagent/src/bootstrap.ts +1 -1
- package/plugins/carthagent/src/daily-evidence.mjs +57 -0
- package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
- package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
- package/plugins/carthagent/src/index.ts +71 -24
- package/plugins/carthagent/src/rpc.ts +51 -6
- package/plugins/carthagent/src/server/codegraph.ts +12 -5
- package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
- package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
- package/plugins/carthagent/src/server/delivery.ts +227 -112
- package/plugins/carthagent/src/server/framing.mjs +80 -0
- package/plugins/carthagent/src/server/policy.ts +1 -1
- package/plugins/carthagent/src/server/providers.ts +59 -31
- package/plugins/carthagent/src/server/session-controls.ts +3 -1
- package/plugins/carthagent/src/server/ship-agents.ts +77 -0
- package/plugins/carthagent/src/server/ship-context.mjs +60 -0
- package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +496 -0
- package/plugins/carthagent/src/ui/client.ts +127 -0
- package/plugins/carthagent/src/ui/commands.ts +70 -0
- package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
- package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
- package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
- package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
- package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/model-command.ts +32 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
- package/plugins/carthagent/src/ui/native-review.mjs +159 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
- package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
- package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
- package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
- package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
- package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
- package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
- package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
- package/plugins/carthagent/src/ui/voyages.tsx +131 -0
- package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
- package/plugins/carthagent/src/ui/workspace.tsx +379 -0
- package/plugins/carthagent/src/voyages-rpc.ts +64 -0
- package/plugins/carthagent/src/workspace-rpc.ts +36 -0
- package/plugins/carthagent/tests/commands.test.ts +107 -0
- package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
- package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
- package/plugins/carthagent/themes/carthage.json +67 -0
- package/plugins/carthagent/tui.ts +26 -1
- package/script/ctg/build-owned-runtime.sh +166 -0
- package/tests/budget-engine.test.mjs +4 -3
- package/tests/cli.test.mjs +52 -96
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +57 -2
- package/tests/delivery.test.mjs +22 -1
- package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
- package/tests/generate-owned-runtime-release.test.mjs +354 -0
- package/tests/installed-runtime-contract.test.mjs +43 -0
- package/tests/native-cli-host-smoke.mjs +457 -0
- package/tests/native-daily-editor-focus.test.mjs +213 -0
- package/tests/native-daily-editor.test.mjs +43 -0
- package/tests/native-daily-quests-layout.test.mjs +55 -0
- package/tests/native-daily-quests-rpc.test.mjs +456 -0
- package/tests/native-daily-quests-ui.test.mjs +778 -0
- package/tests/native-daily-source.test.mjs +525 -0
- package/tests/native-demo-ui.test.mjs +48 -0
- package/tests/native-evaluation-admission.test.mjs +24 -0
- package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
- package/tests/native-fixture-runtime.test.mjs +111 -0
- package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
- package/tests/native-glyph-pty.test.mjs +107 -0
- package/tests/native-manual-projects.test.mjs +170 -0
- package/tests/native-model-command.test.mjs +67 -0
- package/tests/native-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +31 -0
- package/tests/native-pty-invocation.test.mjs +114 -0
- package/tests/native-pty-runtime.test.mjs +45 -0
- package/tests/native-resume-session.test.mjs +46 -0
- package/tests/native-review-consumer.test.mjs +286 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-select-mouse.test.mjs +23 -0
- package/tests/native-ship-guidance.test.mjs +16 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +85 -0
- package/tests/native-ship-rpc.test.mjs +71 -0
- package/tests/native-ui-commands.test.mjs +6 -0
- package/tests/native-voyages-layout.test.mjs +28 -0
- package/tests/native-voyages-navigation.test.mjs +169 -0
- package/tests/native-voyages-rpc.test.mjs +397 -0
- package/tests/native-voyages-ui.test.mjs +307 -0
- package/tests/opencode-atomic-write.test.mjs +55 -0
- package/tests/opencode-cli.test.mjs +1312 -0
- package/tests/opencode-codegraph-plugin.test.mjs +47 -0
- package/tests/opencode-delivery.test.mjs +16 -0
- package/tests/opencode-providers.test.mjs +245 -18
- package/tests/opencode-runtime.test.mjs +597 -8
- package/tests/opencode-session-controls.test.mjs +15 -0
- package/tests/opencode-ship-rpc.test.mjs +71 -0
- package/tests/opencode-update.test.mjs +475 -0
- package/tests/opencode-workspace.test.mjs +51 -2
- package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
- package/tests/release-workflow.test.mjs +46 -0
- package/tests/runtime-artifact.test.mjs +269 -0
- package/tests/runtime-development-trust.test.mjs +231 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/server.test.mjs +3 -1
- package/tests/ship-telemetry.test.mjs +1 -1
- package/tests/source-preview.test.mjs +253 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/update.test.mjs +42 -5
- package/tests/verify-draft-runtime-gate.test.mjs +280 -0
- package/tests/verify-owned-runtime-release.test.mjs +371 -0
- package/tests/workspace-live.test.mjs +127 -0
- package/tools/benchmark/diagnose-request.mjs +59 -36
- package/tools/benchmark/eval-common.mjs +3 -3
- package/tools/benchmark/evaluators.test.mjs +4 -4
- package/tools/benchmark/guard.test.mjs +35 -2
- package/tools/benchmark/guard.ts +20 -7
- package/tools/benchmark/planning-startup.test.mjs +30 -34
- package/tools/benchmark/runner.mjs +12 -7
- package/tools/benchmark/runner.test.mjs +23 -7
- package/tools/benchmark/upstream-error.test.mjs +16 -2
- package/tools/generate-owned-runtime-release.mjs +217 -0
- package/tools/installed-runtime-contract.mjs +16 -0
- package/tools/manual-runtime-manifest.mjs +39 -0
- package/tools/native-fixture-browser-cleanup.mjs +59 -0
- package/tools/native-fixture-stop-diagnostics.mjs +43 -0
- package/tools/native-fixture.mjs +199 -40
- package/tools/native-glyph-helpers.py +127 -0
- package/tools/native-glyph-pty.py +144 -0
- package/tools/native-manual-projects.mjs +259 -0
- package/tools/native-manual.mjs +338 -0
- package/tools/native-model-picker-acceptance.py +306 -0
- package/tools/native-owned-runtime-env.sh +47 -0
- package/tools/native-paired-guards.mjs +282 -0
- package/tools/native-paired-manifest.mjs +72 -0
- package/tools/native-paired-orchestration.sh +51 -0
- package/tools/native-plugin-readiness.mjs +33 -0
- package/tools/native-plugin-readiness.test.mjs +53 -0
- package/tools/native-pty-invocation.mjs +26 -0
- package/tools/native-pty-runtime.mjs +83 -0
- package/tools/native-pty-smoke.py +147 -24
- package/tools/native-public-repo-shell-result.mjs +13 -0
- package/tools/native-public-repo-shell-result.test.mjs +28 -0
- package/tools/native-runtime-identity.mjs +201 -0
- package/tools/native-slash-acceptance-env.py +38 -0
- package/tools/native-terminal-semantics.py +876 -0
- package/tools/owned-shell-observer/index.ts +16 -0
- package/tools/owned-shell-observer/package.json +1 -0
- package/tools/owned-visual-clear-env.py +48 -0
- package/tools/owned-visual-clear-model.py +21 -0
- package/tools/pinned-owned-runtime-inputs.mjs +46 -0
- package/tools/prepare-native-manual.mjs +122 -0
- package/tools/run-native-terminal-paired.sh +403 -0
- package/tools/runtime-token-env.mjs +8 -0
- package/tools/seed-native-switch-fixture.mjs +79 -0
- package/tools/test-native-controls.mjs +3 -1
- package/tools/test-native-daily-quests.mjs +65 -0
- package/tools/test-native-default-cli.mjs +106 -0
- package/tools/test-native-delivery-host.mjs +102 -20
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +87 -63
- package/tools/test-native-outcomes.mjs +3 -1
- package/tools/test-native-package.mjs +206 -38
- package/tools/test-native-performance.mjs +26 -0
- package/tools/test-native-product.mjs +90 -0
- package/tools/test-native-pty.mjs +34 -9
- package/tools/test-native-public-repo.mjs +224 -0
- package/tools/test-native-refresh-reentry.mjs +106 -0
- package/tools/test-native-report.mjs +27 -20
- package/tools/test-native-review.mjs +75 -0
- package/tools/test-native-runtime.mjs +120 -0
- package/tools/test-native-ship-models.mjs +176 -0
- package/tools/test-native-ship-walkthrough.mjs +96 -0
- package/tools/test-native-slash-commands.mjs +125 -0
- package/tools/test-native-slash-commands.py +242 -0
- package/tools/test-native-swe-host.mjs +224 -55
- package/tools/test-native-tasks.mjs +77 -0
- package/tools/test-native-terminal-performance.py +887 -0
- package/tools/test-native-terminal-readiness.sh +85 -0
- package/tools/test-native-terminal-semantics-installed.mjs +38 -0
- package/tools/test-native-visual-clear-installed.mjs +48 -0
- package/tools/test-owned-host-controls.mjs +238 -0
- package/tools/test-owned-opencode-runtime.mjs +35 -0
- package/tools/test-owned-visual-clear-env.py +120 -0
- package/tools/test-owned-visual-clear.py +373 -0
- package/tools/verify-draft-runtime-gate.mjs +138 -0
- package/tools/verify-owned-runtime-release.mjs +114 -0
- package/tools/verify-release-version.mjs +21 -1
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Opt-in real-PTY input, warm-session-switch, idle-output and RSS probe.
|
|
3
|
+
|
|
4
|
+
Run on a POSIX host with the pinned 2.0.32 binary and installed dependencies.
|
|
5
|
+
This measures the interactive terminal (including CTG plugin work), not RPC.
|
|
6
|
+
Startup and initial warm-up are excluded from latency samples.
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import datetime
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import pathlib
|
|
14
|
+
import re
|
|
15
|
+
import signal
|
|
16
|
+
import select
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
SAMPLES = 30
|
|
24
|
+
TARGET_INPUT_MS = 50
|
|
25
|
+
TARGET_SWITCH_MS = 150
|
|
26
|
+
ANSI = re.compile(rb"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def percentile95(values):
|
|
30
|
+
ordered = sorted(values)
|
|
31
|
+
return ordered[(len(ordered) * 95 + 99) // 100 - 1]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def host_conditions():
|
|
35
|
+
result = {"capturedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
36
|
+
"cpuCount": os.cpu_count()}
|
|
37
|
+
try:
|
|
38
|
+
result["loadAverage1m5m15m"] = [round(value, 2) for value in os.getloadavg()]
|
|
39
|
+
except (AttributeError, OSError, ValueError):
|
|
40
|
+
result["loadAverage1m5m15m"] = None
|
|
41
|
+
try:
|
|
42
|
+
values = {line.split(":", 1)[0]: int(line.split()[1])
|
|
43
|
+
for line in pathlib.Path("/proc/meminfo").read_text().splitlines()
|
|
44
|
+
if ":" in line and line.split()[1].isdigit()}
|
|
45
|
+
result["hostMemoryMb"] = {key: round(values[key] / 1024, 1)
|
|
46
|
+
for key in ("MemTotal", "MemAvailable") if key in values}
|
|
47
|
+
except OSError:
|
|
48
|
+
result["hostMemoryMb"] = None
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def utc_now():
|
|
53
|
+
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class HostSampler:
|
|
57
|
+
"""Capture host load and available memory across the timed measurement window."""
|
|
58
|
+
def __init__(self, interval):
|
|
59
|
+
self.interval = interval
|
|
60
|
+
self.started_ns = None
|
|
61
|
+
self.samples = []
|
|
62
|
+
self._stop = threading.Event()
|
|
63
|
+
self._thread = None
|
|
64
|
+
|
|
65
|
+
def start(self):
|
|
66
|
+
self.started_ns = time.monotonic_ns()
|
|
67
|
+
self._sample()
|
|
68
|
+
self._thread = threading.Thread(target=self._run, name="native-perf-host-sampler", daemon=True)
|
|
69
|
+
self._thread.start()
|
|
70
|
+
|
|
71
|
+
def _sample(self):
|
|
72
|
+
sample = host_conditions()
|
|
73
|
+
sample["elapsedNs"] = time.monotonic_ns() - self.started_ns
|
|
74
|
+
self.samples.append(sample)
|
|
75
|
+
|
|
76
|
+
def _run(self):
|
|
77
|
+
while not self._stop.wait(self.interval):
|
|
78
|
+
self._sample()
|
|
79
|
+
|
|
80
|
+
def stop(self):
|
|
81
|
+
self._stop.set()
|
|
82
|
+
if self._thread:
|
|
83
|
+
self._thread.join()
|
|
84
|
+
self._sample()
|
|
85
|
+
return self.samples
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Terminal:
|
|
89
|
+
def __init__(self, master, child):
|
|
90
|
+
self.master, self.child = master, child
|
|
91
|
+
self.transcript = bytearray()
|
|
92
|
+
self.screen = Screen()
|
|
93
|
+
self.pollCount = 0
|
|
94
|
+
self.pollWaitNs = 0
|
|
95
|
+
self.readNs = 0
|
|
96
|
+
self.screenFeedNs = 0
|
|
97
|
+
self.detectionNs = 0
|
|
98
|
+
|
|
99
|
+
def drain(self, timeout=0.05):
|
|
100
|
+
if self.child.poll() is not None:
|
|
101
|
+
tail = ANSI.sub(b"", bytes(self.transcript)).decode(errors="replace")[-2000:]
|
|
102
|
+
raise RuntimeError(f"native TUI exited with {self.child.returncode}; screen={self.screen.dump()[-1200:]}; output={tail}")
|
|
103
|
+
poll_started = time.perf_counter_ns()
|
|
104
|
+
ready = select.select([self.master], [], [], timeout)[0]
|
|
105
|
+
self.pollCount += 1
|
|
106
|
+
self.pollWaitNs += time.perf_counter_ns() - poll_started
|
|
107
|
+
if not ready:
|
|
108
|
+
return b""
|
|
109
|
+
try:
|
|
110
|
+
read_started = time.perf_counter_ns()
|
|
111
|
+
chunk = os.read(self.master, 65536)
|
|
112
|
+
self.readNs += time.perf_counter_ns() - read_started
|
|
113
|
+
except OSError:
|
|
114
|
+
return b""
|
|
115
|
+
self.transcript.extend(chunk)
|
|
116
|
+
feed_started = time.perf_counter_ns()
|
|
117
|
+
self.screen.feed(chunk)
|
|
118
|
+
self.screenFeedNs += time.perf_counter_ns() - feed_started
|
|
119
|
+
# Reply to terminal capability probes in a headless PTY.
|
|
120
|
+
if b"\x1b[6n" in chunk:
|
|
121
|
+
os.write(self.master, b"\x1b[1;1R")
|
|
122
|
+
if b"\x1b[c" in chunk:
|
|
123
|
+
os.write(self.master, b"\x1b[?1;2c")
|
|
124
|
+
return chunk
|
|
125
|
+
|
|
126
|
+
def collect_until(self, predicate, timeout, description, poll_interval=0.05):
|
|
127
|
+
deadline = time.monotonic() + timeout
|
|
128
|
+
seen = bytearray()
|
|
129
|
+
while time.monotonic() < deadline:
|
|
130
|
+
seen.extend(self.drain(min(poll_interval, deadline - time.monotonic())))
|
|
131
|
+
predicate_started = time.perf_counter_ns()
|
|
132
|
+
matched = predicate(bytes(seen))
|
|
133
|
+
self.detectionNs += time.perf_counter_ns() - predicate_started
|
|
134
|
+
if matched:
|
|
135
|
+
return bytes(seen)
|
|
136
|
+
plain = ANSI.sub(b"", bytes(seen)).decode(errors="replace")
|
|
137
|
+
raise TimeoutError(f"Timed out waiting for {description}; parsed screen: {self.screen.dump()[-1800:]}; output tail: {plain[-1500:]}")
|
|
138
|
+
|
|
139
|
+
def send(self, data):
|
|
140
|
+
os.write(self.master, data)
|
|
141
|
+
|
|
142
|
+
def observer_snapshot(self):
|
|
143
|
+
return {"pollCount": self.pollCount, "pollWaitNs": self.pollWaitNs,
|
|
144
|
+
"readNs": self.readNs, "screenFeedNs": self.screenFeedNs,
|
|
145
|
+
"detectionNs": self.detectionNs}
|
|
146
|
+
|
|
147
|
+
def observer_delta(self, before):
|
|
148
|
+
after = self.observer_snapshot()
|
|
149
|
+
return {key: after[key] - before[key] for key in after}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Screen:
|
|
153
|
+
"""Small deterministic VT screen model for rendered-cell assertions."""
|
|
154
|
+
def __init__(self, rows=36, cols=120):
|
|
155
|
+
self.rows, self.cols = rows, cols
|
|
156
|
+
self.cells = [[" "] * cols for _ in range(rows)]
|
|
157
|
+
self.row = self.col = 0
|
|
158
|
+
self.state = "text"
|
|
159
|
+
self.sequence = ""
|
|
160
|
+
self.pending = b""
|
|
161
|
+
|
|
162
|
+
def _put(self, char):
|
|
163
|
+
if char == "\n":
|
|
164
|
+
self.row = min(self.rows - 1, self.row + 1)
|
|
165
|
+
elif char == "\r":
|
|
166
|
+
self.col = 0
|
|
167
|
+
elif char == "\b":
|
|
168
|
+
self.col = max(0, self.col - 1)
|
|
169
|
+
elif char == "\t":
|
|
170
|
+
self.col = min(self.cols - 1, (self.col // 8 + 1) * 8)
|
|
171
|
+
elif char >= " " and char != "\x7f":
|
|
172
|
+
if self.col >= self.cols:
|
|
173
|
+
self.col = 0
|
|
174
|
+
self.row = min(self.rows - 1, self.row + 1)
|
|
175
|
+
self.cells[self.row][self.col] = char
|
|
176
|
+
self.col += 1
|
|
177
|
+
|
|
178
|
+
def _csi(self, final):
|
|
179
|
+
raw = self.sequence[2:-1]
|
|
180
|
+
params = [int(part) if part.isdigit() else 0 for part in raw.split(";")]
|
|
181
|
+
n = params[0] or 1
|
|
182
|
+
if final in "Hf":
|
|
183
|
+
self.row = max(0, min(self.rows - 1, (params[0] or 1) - 1))
|
|
184
|
+
self.col = max(0, min(self.cols - 1, (params[1] if len(params) > 1 and params[1] else 1) - 1))
|
|
185
|
+
elif final == "A": self.row = max(0, self.row - n)
|
|
186
|
+
elif final == "B": self.row = min(self.rows - 1, self.row + n)
|
|
187
|
+
elif final == "C": self.col = min(self.cols - 1, self.col + n)
|
|
188
|
+
elif final == "D": self.col = max(0, self.col - n)
|
|
189
|
+
elif final == "G": self.col = max(0, min(self.cols - 1, n - 1))
|
|
190
|
+
elif final == "d": self.row = max(0, min(self.rows - 1, n - 1))
|
|
191
|
+
elif final == "K":
|
|
192
|
+
mode = params[0] if params else 0
|
|
193
|
+
start = 0 if mode == 1 else self.col
|
|
194
|
+
end = self.col + 1 if mode == 1 else self.cols if mode == 2 else self.cols
|
|
195
|
+
for col in range(start, end): self.cells[self.row][col] = " "
|
|
196
|
+
elif final == "J":
|
|
197
|
+
mode = params[0] if params else 0
|
|
198
|
+
if mode == 2 or mode == 3:
|
|
199
|
+
self.cells = [[" "] * self.cols for _ in range(self.rows)]
|
|
200
|
+
elif mode == 0:
|
|
201
|
+
for col in range(self.col, self.cols): self.cells[self.row][col] = " "
|
|
202
|
+
for row in range(self.row + 1, self.rows): self.cells[row] = [" "] * self.cols
|
|
203
|
+
elif final == "X":
|
|
204
|
+
for col in range(self.col, min(self.cols, self.col + n)): self.cells[self.row][col] = " "
|
|
205
|
+
elif final == "s":
|
|
206
|
+
self.saved = (self.row, self.col)
|
|
207
|
+
elif final == "u" and hasattr(self, "saved"):
|
|
208
|
+
self.row, self.col = self.saved
|
|
209
|
+
|
|
210
|
+
def feed(self, data):
|
|
211
|
+
decoded = self.pending + data
|
|
212
|
+
text = decoded.decode("utf-8", errors="replace")
|
|
213
|
+
self.pending = b""
|
|
214
|
+
# Retain an incomplete trailing UTF-8 sequence for the next PTY chunk.
|
|
215
|
+
encoded = text.encode("utf-8")
|
|
216
|
+
if encoded != decoded:
|
|
217
|
+
for length in range(1, min(4, len(decoded)) + 1):
|
|
218
|
+
try:
|
|
219
|
+
text = decoded[:-length].decode("utf-8")
|
|
220
|
+
self.pending = decoded[-length:]
|
|
221
|
+
break
|
|
222
|
+
except UnicodeDecodeError:
|
|
223
|
+
continue
|
|
224
|
+
for char in text:
|
|
225
|
+
if self.state == "esc":
|
|
226
|
+
if char == "[": self.state, self.sequence = "csi", "\x1b["
|
|
227
|
+
elif char == "]": self.state = "osc"
|
|
228
|
+
elif char == "7": self.state = "text"
|
|
229
|
+
else: self.state = "text"
|
|
230
|
+
elif self.state == "osc":
|
|
231
|
+
if char == "\x07": self.state = "text"
|
|
232
|
+
elif char == "\x1b": self.state = "osc-esc"
|
|
233
|
+
elif self.state == "osc-esc":
|
|
234
|
+
self.state = "text" if char == "\\" else "osc"
|
|
235
|
+
elif self.state == "csi":
|
|
236
|
+
self.sequence += char
|
|
237
|
+
if "@" <= char <= "~":
|
|
238
|
+
self._csi(char)
|
|
239
|
+
self.state, self.sequence = "text", ""
|
|
240
|
+
elif char == "\x1b": self.state = "esc"
|
|
241
|
+
else: self._put(char)
|
|
242
|
+
|
|
243
|
+
def lines(self):
|
|
244
|
+
return ["".join(row).rstrip() for row in self.cells]
|
|
245
|
+
|
|
246
|
+
def contains(self, text):
|
|
247
|
+
return any(text in line for line in self.lines())
|
|
248
|
+
|
|
249
|
+
def dump(self):
|
|
250
|
+
return "\n".join(self.lines())
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def locate_text(screen, text):
|
|
254
|
+
for row, line in enumerate(screen.lines()):
|
|
255
|
+
col = line.find(text)
|
|
256
|
+
if col >= 0:
|
|
257
|
+
return row, col
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
NATIVE_EDITOR_PLACEHOLDER = "Message carthagent…"
|
|
262
|
+
WORKSPACE_RENDER_SENTINELS = ("CARTHAGENT · WORKSPACE", "Workspace native sessions")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def editor_contains_typed_cell(screen, marker, before, row, editor_col):
|
|
266
|
+
# The native placeholder's starting cell is the input origin. Restrict the
|
|
267
|
+
# assertion to that exact cell; other changes on the editor row do not count.
|
|
268
|
+
return (screen.cells[row][editor_col] == marker and
|
|
269
|
+
before[row][editor_col] != marker)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def workspace_render_completed(screen, before_lines):
|
|
273
|
+
"""Require fresh, visible plugin route and data-backed workspace section."""
|
|
274
|
+
current = screen.lines()
|
|
275
|
+
previous = "\n".join(before_lines)
|
|
276
|
+
rendered = "\n".join(current)
|
|
277
|
+
return all(sentinel in rendered and sentinel not in previous
|
|
278
|
+
for sentinel in WORKSPACE_RENDER_SENTINELS)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def workspace_command_staged(screen, editor_origin):
|
|
282
|
+
rendered = "\n".join(screen.lines())
|
|
283
|
+
row, column = editor_origin
|
|
284
|
+
staged = "".join(screen.cells[row][column:column + len("/ctg-workspace")])
|
|
285
|
+
return (staged == "/ctg-workspace" and
|
|
286
|
+
"Carthagent commands" not in rendered and
|
|
287
|
+
"Search commands" not in rendered and
|
|
288
|
+
NATIVE_EDITOR_PLACEHOLDER not in rendered)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main():
|
|
292
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
293
|
+
parser.add_argument("--binary", default=os.environ.get("CTG_OPENCODE_BIN") or shutil.which("opencode"))
|
|
294
|
+
parser.add_argument("--plugin", default=str(pathlib.Path(__file__).resolve().parents[1] / "plugins/carthagent"))
|
|
295
|
+
parser.add_argument("--switch-fixture", help="JSON emitted by seed-native-switch-fixture.mjs; enables native Open Sessions switching")
|
|
296
|
+
parser.add_argument("--readiness-only", action="store_true",
|
|
297
|
+
help="bounded, non-timed real-PTY editor echo/backspace check; no performance samples")
|
|
298
|
+
parser.add_argument("--no-plugin", action="store_true",
|
|
299
|
+
help="measure the native baseline without registering the CTG plugin")
|
|
300
|
+
parser.add_argument("--samples", type=int, default=SAMPLES)
|
|
301
|
+
parser.add_argument("--host-sample-interval", type=float, default=1.0,
|
|
302
|
+
help="seconds between load/memory observations during measured samples")
|
|
303
|
+
parser.add_argument("--idle-seconds", type=float, default=10,
|
|
304
|
+
help="bounded idle observation window (default: 10s; max: 300s)")
|
|
305
|
+
parser.add_argument("--idle-byte-limit", type=int, default=65536,
|
|
306
|
+
help="report idle-output budget; status-clock bytes are reported, not presumed a defect")
|
|
307
|
+
parser.add_argument("--memory-growth-limit-mb", type=int, default=None,
|
|
308
|
+
help="optional operator-approved RSS growth threshold; no product threshold is currently defined")
|
|
309
|
+
parser.add_argument("--report", help="write machine-readable report JSON")
|
|
310
|
+
args = parser.parse_args()
|
|
311
|
+
if os.name != "posix":
|
|
312
|
+
parser.error("real PTY performance probe requires POSIX")
|
|
313
|
+
if not args.binary:
|
|
314
|
+
parser.error("provide --binary or CTG_OPENCODE_BIN for the owned 2.0.32 executable")
|
|
315
|
+
if args.samples < 30 and not args.readiness_only:
|
|
316
|
+
parser.error("at least 30 samples are required")
|
|
317
|
+
if not 1 <= args.idle_seconds <= 300:
|
|
318
|
+
parser.error("idle window must be between 1 and 300 seconds")
|
|
319
|
+
if not 0.1 <= args.host_sample_interval <= 30:
|
|
320
|
+
parser.error("host sample interval must be between 0.1 and 30 seconds")
|
|
321
|
+
binary = str(pathlib.Path(args.binary).resolve())
|
|
322
|
+
fixture = json.loads(pathlib.Path(args.switch_fixture).read_text()) if args.switch_fixture else None
|
|
323
|
+
if fixture:
|
|
324
|
+
observed = fixture.get("pluginObservation", {}).get("observed")
|
|
325
|
+
native_inventory = fixture.get("pluginInventory")
|
|
326
|
+
inventory_ids = [item.get("id") for item in native_inventory if isinstance(item, dict)] if isinstance(native_inventory, list) else None
|
|
327
|
+
expected = os.environ.get("CTG_PERF_EXPECT_PLUGIN")
|
|
328
|
+
if observed not in ("active", "absent") or expected != observed:
|
|
329
|
+
parser.error("fixture must match the expected observed native plugin inventory state")
|
|
330
|
+
if inventory_ids is None:
|
|
331
|
+
parser.error("fixture must include the native plugin.list inventory observation")
|
|
332
|
+
if observed == "active" and not any(isinstance(item, dict) and item.get("id") == "carthagent" and
|
|
333
|
+
isinstance(item.get("state"), dict) and item["state"].get("status") == "active"
|
|
334
|
+
for item in native_inventory):
|
|
335
|
+
parser.error("native plugin.list inventory does not show Carthagent active")
|
|
336
|
+
if observed == "absent" and any(isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"].startswith("carthagent") for item in native_inventory):
|
|
337
|
+
parser.error("baseline native plugin.list inventory contains a Carthagent plugin")
|
|
338
|
+
runtime_identity = fixture.get("runtimeIdentity")
|
|
339
|
+
if not isinstance(runtime_identity, dict) or runtime_identity.get("compatibilityVersion") != "2.0.32":
|
|
340
|
+
parser.error("fixture lacks verified native runtime compatibility identity")
|
|
341
|
+
if runtime_identity.get("binary") != str(pathlib.Path(binary).resolve()):
|
|
342
|
+
parser.error("fixture runtime executable identity does not match the measured executable")
|
|
343
|
+
if runtime_identity.get("binarySha256") and runtime_identity.get("binarySha256") != hashlib.sha256(pathlib.Path(binary).read_bytes()).hexdigest():
|
|
344
|
+
parser.error("fixture runtime binary hash does not match the measured executable")
|
|
345
|
+
runtime_version = runtime_identity.get("runtimeVersion")
|
|
346
|
+
if not isinstance(runtime_version, str) or not runtime_version:
|
|
347
|
+
parser.error("fixture lacks exact verified native runtime version")
|
|
348
|
+
if observed == "active" and (fixture.get("pluginHealth", {}).get("hostVersion") != runtime_version or
|
|
349
|
+
fixture.get("pluginHealth", {}).get("protocol") != 1 or
|
|
350
|
+
fixture.get("pluginHealth", {}).get("directoryMatches") is not True):
|
|
351
|
+
parser.error("CTG fixture is missing a successful native health RPC verification")
|
|
352
|
+
if fixture.get("provenance", {}).get("binarySha256") != hashlib.sha256(pathlib.Path(binary).read_bytes()).hexdigest():
|
|
353
|
+
parser.error("fixture binary hash does not match the executable being measured")
|
|
354
|
+
if fixture.get("provenance", {}).get("binaryVersion") != runtime_identity.get("compatibilityVersion"):
|
|
355
|
+
parser.error("fixture provenance compatibility version does not match verified runtime identity")
|
|
356
|
+
if fixture.get("provenance", {}).get("runtimeVersion", runtime_identity.get("runtimeVersion")) != runtime_identity.get("runtimeVersion"):
|
|
357
|
+
parser.error("fixture provenance runtime version does not match verified runtime identity")
|
|
358
|
+
if fixture.get("provenance", {}).get("binarySha256") != runtime_identity.get("binarySha256"):
|
|
359
|
+
parser.error("fixture provenance binary hash does not match verified runtime identity")
|
|
360
|
+
if not os.environ.get("CTG_PERF_SOURCE_COMMIT") or fixture.get("provenance", {}).get("sourceCommit") != os.environ["CTG_PERF_SOURCE_COMMIT"]:
|
|
361
|
+
parser.error("fixture source commit does not match selected immutable execution snapshot")
|
|
362
|
+
if fixture.get("provenance", {}).get("sourceSnapshotSha256") != os.environ.get("CTG_PERF_SOURCE_SNAPSHOT_SHA256"):
|
|
363
|
+
parser.error("fixture execution snapshot hash does not match runner snapshot")
|
|
364
|
+
if observed == "active" and not fixture.get("provenance", {}).get("sourceSnapshotSha256"):
|
|
365
|
+
parser.error("active CTG fixture is missing immutable source snapshot provenance")
|
|
366
|
+
if not fixture.get("roots", {}).get("alpha") or not fixture.get("sessions", {}).get("alpha", {}).get("id") or not fixture.get("sessions", {}).get("bravo", {}).get("id"):
|
|
367
|
+
parser.error("paired fixture must include both disposable session destinations")
|
|
368
|
+
owns_root = fixture is None
|
|
369
|
+
root = pathlib.Path(tempfile.mkdtemp(prefix="ctg-native-terminal-perf-")) if owns_root else pathlib.Path(fixture["temp"])
|
|
370
|
+
if owns_root:
|
|
371
|
+
for name in ("home", "project", "config/opencode", "data", "state", "cache"):
|
|
372
|
+
(root / name).mkdir(parents=True, exist_ok=True)
|
|
373
|
+
config = {"update": "disable"}
|
|
374
|
+
if not args.no_plugin:
|
|
375
|
+
config["plugins"] = [str(pathlib.Path(args.plugin).resolve())]
|
|
376
|
+
(root / "config/opencode/opencode.json").write_text(json.dumps(config))
|
|
377
|
+
env = {key: os.environ[key] for key in ("PATH", "LANG", "LC_ALL") if key in os.environ}
|
|
378
|
+
env.update({
|
|
379
|
+
"HOME": str(root / "home"), "USERPROFILE": str(root / "home"),
|
|
380
|
+
"TERM": "xterm-256color", "COLORTERM": "truecolor",
|
|
381
|
+
"XDG_CONFIG_HOME": str(root / "config"), "XDG_DATA_HOME": str(root / "data"),
|
|
382
|
+
"XDG_STATE_HOME": str(root / "state"), "XDG_CACHE_HOME": str(root / "cache"),
|
|
383
|
+
"OPENCODE_DB": str(root / "database.sqlite"), "OPENCODE_CONFIG_PROJECT_DISABLE": "true",
|
|
384
|
+
})
|
|
385
|
+
if fixture:
|
|
386
|
+
env["OPENCODE_PASSWORD"] = fixture["password"]
|
|
387
|
+
env["CARTHAGENT_CODING_AGENT_DIR"] = str(root / "home/.carthagent/agent")
|
|
388
|
+
env["PI_CODING_AGENT_DIR"] = str(root / "home/.carthagent/agent")
|
|
389
|
+
env["HOME"] = str(root / "home")
|
|
390
|
+
env["NODE_PATH"] = os.environ.get("NODE_PATH", "")
|
|
391
|
+
cwd = pathlib.Path(fixture["roots"]["alpha"])
|
|
392
|
+
startup_session = (fixture.get("readinessSession", {}).get("id")
|
|
393
|
+
if args.readiness_only and fixture.get("pluginObservation", {}).get("observed") == "active"
|
|
394
|
+
else fixture["sessions"]["alpha"]["id"])
|
|
395
|
+
cwd.mkdir(parents=True, exist_ok=True)
|
|
396
|
+
# The seeder has already started the pinned server once, so the host's
|
|
397
|
+
# plugin/config/profile layout and database are all initialized here.
|
|
398
|
+
else:
|
|
399
|
+
cwd = root / "project"
|
|
400
|
+
startup_session = None
|
|
401
|
+
# Keep pure screen/editor/workspace helpers importable on Windows. The
|
|
402
|
+
# real probe remains POSIX-only and loads its PTY APIs only when executing.
|
|
403
|
+
import fcntl
|
|
404
|
+
import pty
|
|
405
|
+
import struct
|
|
406
|
+
import termios
|
|
407
|
+
|
|
408
|
+
master, slave = pty.openpty()
|
|
409
|
+
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 36, 120, 0, 0))
|
|
410
|
+
command = [binary, "--standalone"]
|
|
411
|
+
if fixture:
|
|
412
|
+
command = [binary, "--server", fixture["baseUrl"], "--session", startup_session]
|
|
413
|
+
try:
|
|
414
|
+
child = subprocess.Popen(command, cwd=cwd, env=env,
|
|
415
|
+
stdin=slave, stdout=slave, stderr=slave, start_new_session=True)
|
|
416
|
+
except BaseException:
|
|
417
|
+
os.close(master)
|
|
418
|
+
os.close(slave)
|
|
419
|
+
if owns_root:
|
|
420
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
421
|
+
raise
|
|
422
|
+
os.close(slave)
|
|
423
|
+
terminal = Terminal(master, child)
|
|
424
|
+
previous_sigterm = signal.getsignal(signal.SIGTERM)
|
|
425
|
+
def handle_sigterm(_signum, _frame):
|
|
426
|
+
raise KeyboardInterrupt("received SIGTERM")
|
|
427
|
+
signal.signal(signal.SIGTERM, handle_sigterm)
|
|
428
|
+
try:
|
|
429
|
+
result = {"binary": binary, "expectedHostVersion": "2.0.32",
|
|
430
|
+
"compatibilityVersion": runtime_identity["compatibilityVersion"] if fixture else "2.0.32",
|
|
431
|
+
"runtimeVersion": runtime_identity["runtimeVersion"] if fixture else None,
|
|
432
|
+
"measurement": "real POSIX PTY; native plugin inventory observed active" if fixture and fixture.get("pluginObservation", {}).get("observed") == "active" else "real POSIX PTY; native plugin inventory observed absent",
|
|
433
|
+
"startupIncludedInSamples": False, "samples": args.samples,
|
|
434
|
+
"measurementScope": "terminal interaction through select/read/VT parse and parsed PTY screen evidence; not compositor/GPU presentation; observer overhead is reported and never subtracted",
|
|
435
|
+
"hostConditions": host_conditions()}
|
|
436
|
+
if fixture:
|
|
437
|
+
result["switchFixture"] = str(pathlib.Path(args.switch_fixture).resolve())
|
|
438
|
+
result["pluginObservation"] = fixture.get("pluginObservation")
|
|
439
|
+
result["pluginInventory"] = fixture.get("pluginInventory")
|
|
440
|
+
result["pluginHealth"] = fixture.get("pluginHealth")
|
|
441
|
+
result["provenance"] = fixture.get("provenance")
|
|
442
|
+
result["runtimeIdentity"] = runtime_identity
|
|
443
|
+
result["runtimeVersion"] = runtime_identity["runtimeVersion"]
|
|
444
|
+
result["compatibilityVersion"] = runtime_identity["compatibilityVersion"]
|
|
445
|
+
result["sourceProvenance"] = {"commit": os.environ.get("CTG_PERF_SOURCE_COMMIT"),
|
|
446
|
+
"snapshotSha256": os.environ.get("CTG_PERF_SOURCE_SNAPSHOT_SHA256"),
|
|
447
|
+
"sourceDirectory": os.environ.get("CTG_PERF_SOURCE_DIR")}
|
|
448
|
+
if os.environ.get("CTG_PERF_SOURCE_COMMIT") and not fixture.get("pluginObservation", {}).get("observed"):
|
|
449
|
+
raise RuntimeError("Fixture lacks verified native plugin condition; refusing performance artifact")
|
|
450
|
+
except BaseException:
|
|
451
|
+
signal.signal(signal.SIGTERM, previous_sigterm)
|
|
452
|
+
if child.poll() is None:
|
|
453
|
+
os.killpg(child.pid, signal.SIGTERM)
|
|
454
|
+
try:
|
|
455
|
+
child.wait(timeout=5)
|
|
456
|
+
except subprocess.TimeoutExpired:
|
|
457
|
+
os.killpg(child.pid, signal.SIGKILL)
|
|
458
|
+
child.wait()
|
|
459
|
+
os.close(master)
|
|
460
|
+
if owns_root:
|
|
461
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
462
|
+
raise
|
|
463
|
+
try:
|
|
464
|
+
# Wait on actual TUI readiness; never charge fixed sleeps to an input latency.
|
|
465
|
+
terminal.collect_until(
|
|
466
|
+
(lambda _output: terminal.screen.contains(fixture["readinessSession"]["title"]))
|
|
467
|
+
if args.readiness_only and fixture and fixture.get("pluginObservation", {}).get("observed") == "active" else
|
|
468
|
+
(lambda _output: terminal.screen.contains(fixture["sessions"]["alpha"]["marker"])) if fixture else
|
|
469
|
+
(lambda _output: locate_text(terminal.screen, NATIVE_EDITOR_PLACEHOLDER) is not None),
|
|
470
|
+
90, "native editor placeholder/session")
|
|
471
|
+
# Allow the first genuine frame/plugin setup to settle, outside all sample clocks.
|
|
472
|
+
terminal.drain(0.5)
|
|
473
|
+
if (args.readiness_only and fixture and
|
|
474
|
+
fixture.get("pluginObservation", {}).get("observed") == "absent"):
|
|
475
|
+
readiness = {"readinessOnly": True, "timedSamplesRun": False,
|
|
476
|
+
"readinessType": "baseline-native-plugin-inventory",
|
|
477
|
+
"pluginObservation": fixture.get("pluginObservation"),
|
|
478
|
+
"pluginAbsentVerified": fixture.get("pluginObservation", {}).get("observed") == "absent",
|
|
479
|
+
"fixtureSessionRendered": terminal.screen.contains(fixture["sessions"]["alpha"]["marker"]),
|
|
480
|
+
"measurementScope": "bounded disposable fixture/plugin absence verification; no latency samples or performance claim"}
|
|
481
|
+
if not readiness["pluginAbsentVerified"] or not readiness["fixtureSessionRendered"]:
|
|
482
|
+
raise RuntimeError("Baseline plugin-absence/session readiness was not verified")
|
|
483
|
+
text = json.dumps(readiness, indent=2)
|
|
484
|
+
print(text)
|
|
485
|
+
if args.report:
|
|
486
|
+
pathlib.Path(args.report).write_text(text + "\n")
|
|
487
|
+
return
|
|
488
|
+
if args.readiness_only and fixture:
|
|
489
|
+
# Attach directly to a separately seeded empty session; do not
|
|
490
|
+
# infer or navigate the native new-session keybinding here.
|
|
491
|
+
terminal.collect_until(lambda _output: locate_text(terminal.screen, NATIVE_EDITOR_PLACEHOLDER) is not None,
|
|
492
|
+
90, "native editor placeholder in fresh session")
|
|
493
|
+
switch_ms = []
|
|
494
|
+
switch_observations = []
|
|
495
|
+
input_observations = []
|
|
496
|
+
input_warmup_observation = None
|
|
497
|
+
sampler = HostSampler(args.host_sample_interval)
|
|
498
|
+
switch_note = "not measured: provide --switch-fixture with two preloaded native sessions"
|
|
499
|
+
if fixture and not args.readiness_only:
|
|
500
|
+
sampler.start()
|
|
501
|
+
def select_native_session(session, timed):
|
|
502
|
+
title = session["title"]
|
|
503
|
+
if terminal.screen.contains(session["marker"]):
|
|
504
|
+
if not timed: return None
|
|
505
|
+
raise RuntimeError(f"Switch sample targeted already-active session {session['id']}")
|
|
506
|
+
# In the pinned 2.0.32 TUI Ctrl+O opens Open Sessions. Search
|
|
507
|
+
# by the exact fixture title, then time Enter-to-history render.
|
|
508
|
+
terminal.send(b"\x0f") # open.menu
|
|
509
|
+
terminal.collect_until(lambda _output: locate_text(terminal.screen, "Search sessions and projects") is not None or
|
|
510
|
+
locate_text(terminal.screen, "Recent Sessions") is not None or
|
|
511
|
+
locate_text(terminal.screen, "Open") is not None,
|
|
512
|
+
10, "native Open Sessions picker")
|
|
513
|
+
terminal.send(title.encode())
|
|
514
|
+
terminal.collect_until(lambda _output: locate_text(terminal.screen, title) is not None,
|
|
515
|
+
10, f"native session row {title}")
|
|
516
|
+
if terminal.screen.contains(session["marker"]):
|
|
517
|
+
raise RuntimeError(f"Destination marker {session['marker']} visible before selection")
|
|
518
|
+
observer_before = terminal.observer_snapshot()
|
|
519
|
+
started_at = utc_now()
|
|
520
|
+
started = time.perf_counter_ns()
|
|
521
|
+
started_monotonic_ns = time.monotonic_ns()
|
|
522
|
+
terminal.send(b"\r")
|
|
523
|
+
try:
|
|
524
|
+
terminal.collect_until(lambda _output: terminal.screen.contains(session["marker"]), 10,
|
|
525
|
+
f"unique rendered destination marker {session['marker']}", poll_interval=0.001)
|
|
526
|
+
except Exception as error:
|
|
527
|
+
failed_ns = time.perf_counter_ns()
|
|
528
|
+
failed_monotonic_ns = time.monotonic_ns()
|
|
529
|
+
if timed:
|
|
530
|
+
switch_observations.append({"sample": len(switch_observations) + 1,
|
|
531
|
+
"sessionId": session["id"], "marker": session["marker"],
|
|
532
|
+
"status": "failed", "startedAt": started_at,
|
|
533
|
+
"failedAt": utc_now(),
|
|
534
|
+
"startedMonotonicNs": started_monotonic_ns,
|
|
535
|
+
"failedMonotonicNs": failed_monotonic_ns,
|
|
536
|
+
"elapsedToFailureNs": failed_ns - started,
|
|
537
|
+
"failure": f"{type(error).__name__}: {error}",
|
|
538
|
+
"destinationMarkerVerified": terminal.screen.contains(session["marker"]),
|
|
539
|
+
"observerWork": terminal.observer_delta(observer_before),
|
|
540
|
+
"timingScope": "Enter write through select/read/VT parse and parsed PTY marker observation; not compositor presentation"})
|
|
541
|
+
raise
|
|
542
|
+
completed_ns = time.perf_counter_ns()
|
|
543
|
+
completed_monotonic_ns = time.monotonic_ns()
|
|
544
|
+
completed_at = utc_now()
|
|
545
|
+
elapsed_ns = completed_ns - started
|
|
546
|
+
if timed:
|
|
547
|
+
switch_observations.append({"sessionId": session["id"], "marker": session["marker"],
|
|
548
|
+
"startedAt": started_at, "completedAt": completed_at,
|
|
549
|
+
"startedMonotonicNs": started_monotonic_ns,
|
|
550
|
+
"completedMonotonicNs": completed_monotonic_ns,
|
|
551
|
+
"elapsedNs": elapsed_ns, "elapsedMs": elapsed_ns / 1_000_000,
|
|
552
|
+
"destinationMarkerVerified": terminal.screen.contains(session["marker"]),
|
|
553
|
+
"observerWork": terminal.observer_delta(observer_before),
|
|
554
|
+
"timingScope": "Enter write through select/read/VT parse and parsed PTY marker observation; not compositor presentation"})
|
|
555
|
+
return elapsed_ns / 1_000_000
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
# Visit both targets before the measured 30 alternations so their
|
|
559
|
+
# session files and destination render state have been loaded.
|
|
560
|
+
select_native_session(fixture["sessions"]["bravo"], False)
|
|
561
|
+
select_native_session(fixture["sessions"]["alpha"], False)
|
|
562
|
+
for index in range(args.samples):
|
|
563
|
+
destination = fixture["sessions"]["bravo" if index % 2 == 0 else "alpha"]
|
|
564
|
+
try:
|
|
565
|
+
switch_ms.append(select_native_session(destination, True))
|
|
566
|
+
except Exception as error:
|
|
567
|
+
if not any(sample.get("sample") == index + 1 for sample in switch_observations):
|
|
568
|
+
switch_observations.append({"sample": index + 1, "sessionId": destination["id"],
|
|
569
|
+
"marker": destination["marker"], "status": "failed",
|
|
570
|
+
"failure": f"{type(error).__name__}: {error}",
|
|
571
|
+
"failedAt": utc_now(),
|
|
572
|
+
"timingScope": "Enter write through select/read/VT parse and parsed PTY marker observation; not compositor presentation"})
|
|
573
|
+
switch_ms.append(None)
|
|
574
|
+
try:
|
|
575
|
+
terminal.send(b"\x1b")
|
|
576
|
+
terminal.drain(0.1)
|
|
577
|
+
except Exception:
|
|
578
|
+
pass
|
|
579
|
+
switch_note = f"{args.samples} alternating native Open Sessions menu selections; Enter-to-unique-assistant-history-marker render; both destinations explicitly prewarmed"
|
|
580
|
+
if args.readiness_only:
|
|
581
|
+
terminal.collect_until(lambda _output: locate_text(terminal.screen, NATIVE_EDITOR_PLACEHOLDER) is not None,
|
|
582
|
+
10, "native editor placeholder")
|
|
583
|
+
editor_anchor = locate_text(terminal.screen, NATIVE_EDITOR_PLACEHOLDER)
|
|
584
|
+
editor_origin = None
|
|
585
|
+
if editor_anchor is None:
|
|
586
|
+
raise RuntimeError(f"native editor placeholder {NATIVE_EDITOR_PLACEHOLDER!r} was not present; parsed screen: {terminal.screen.dump()[-1800:]}")
|
|
587
|
+
else:
|
|
588
|
+
editor_row, editor_col = editor_anchor
|
|
589
|
+
editor_origin = (editor_row, editor_col)
|
|
590
|
+
editor_empty_cell = terminal.screen.cells[editor_origin[0]][editor_origin[1]]
|
|
591
|
+
if args.readiness_only:
|
|
592
|
+
if fixture:
|
|
593
|
+
sampler.start()
|
|
594
|
+
before = [row[:] for row in terminal.screen.cells]
|
|
595
|
+
terminal.send(b"z")
|
|
596
|
+
terminal.collect_until(lambda _output: editor_contains_typed_cell(terminal.screen, "z", before, *editor_origin),
|
|
597
|
+
2, "changed typed cell at native editor input origin", poll_interval=0.001)
|
|
598
|
+
typed_cell = terminal.screen.cells[editor_origin[0]][editor_origin[1]]
|
|
599
|
+
terminal.send(b"\x7f")
|
|
600
|
+
terminal.collect_until(lambda _output: terminal.screen.cells[editor_origin[0]][editor_origin[1]] == editor_empty_cell and
|
|
601
|
+
locate_text(terminal.screen, NATIVE_EDITOR_PLACEHOLDER) == editor_origin,
|
|
602
|
+
1, "native editor placeholder restored after backspace", poll_interval=0.001)
|
|
603
|
+
readiness = {"readinessOnly": True, "timedSamplesRun": False,
|
|
604
|
+
"nativeEditorPlaceholder": NATIVE_EDITOR_PLACEHOLDER,
|
|
605
|
+
"inputOrigin": {"row": editor_origin[0], "column": editor_origin[1]},
|
|
606
|
+
"placeholderCell": editor_empty_cell, "typedCell": typed_cell,
|
|
607
|
+
"changedCellAtInputOrigin": before[editor_origin[0]][editor_origin[1]] != typed_cell,
|
|
608
|
+
"restoredPlaceholderVerified": True,
|
|
609
|
+
"pluginObservation": fixture.get("pluginObservation") if fixture else None,
|
|
610
|
+
"measurementScope": "bounded real-PTY readiness/echo only; no latency samples or performance claim"}
|
|
611
|
+
if fixture:
|
|
612
|
+
readiness["hostConditionsDuringReadiness"] = sampler.stop()
|
|
613
|
+
text = json.dumps(readiness, indent=2)
|
|
614
|
+
print(text)
|
|
615
|
+
if args.report:
|
|
616
|
+
pathlib.Path(args.report).write_text(text + "\n")
|
|
617
|
+
return
|
|
618
|
+
input_ms = []
|
|
619
|
+
|
|
620
|
+
def send_and_verify_input(marker, sample_index, timed):
|
|
621
|
+
before = [row[:] for row in terminal.screen.cells]
|
|
622
|
+
observer_before = terminal.observer_snapshot()
|
|
623
|
+
started_at = utc_now()
|
|
624
|
+
started = time.perf_counter_ns()
|
|
625
|
+
started_monotonic_ns = time.monotonic_ns()
|
|
626
|
+
terminal.send(marker.encode())
|
|
627
|
+
deadline = time.monotonic() + 2
|
|
628
|
+
while time.monotonic() < deadline:
|
|
629
|
+
try:
|
|
630
|
+
terminal.drain(0.001)
|
|
631
|
+
except Exception as error:
|
|
632
|
+
if timed:
|
|
633
|
+
input_observations.append({"sample": sample_index, "key": marker, "status": "failed",
|
|
634
|
+
"startedAt": started_at, "failedAt": utc_now(),
|
|
635
|
+
"startedMonotonicNs": started_monotonic_ns,
|
|
636
|
+
"failedMonotonicNs": time.monotonic_ns(),
|
|
637
|
+
"failure": f"{type(error).__name__}: {error}"})
|
|
638
|
+
raise
|
|
639
|
+
detect_started = time.perf_counter_ns()
|
|
640
|
+
detected = editor_contains_typed_cell(terminal.screen, marker, before, *editor_origin)
|
|
641
|
+
terminal.detectionNs += time.perf_counter_ns() - detect_started
|
|
642
|
+
if detected:
|
|
643
|
+
completed_ns = time.perf_counter_ns()
|
|
644
|
+
completed_monotonic_ns = time.monotonic_ns()
|
|
645
|
+
completed_at = utc_now()
|
|
646
|
+
elapsed_ns = completed_ns - started
|
|
647
|
+
observation = {"sample": sample_index, "key": marker, "status": "passed",
|
|
648
|
+
"startedAt": started_at, "completedAt": completed_at,
|
|
649
|
+
"startedMonotonicNs": started_monotonic_ns,
|
|
650
|
+
"completedMonotonicNs": completed_monotonic_ns,
|
|
651
|
+
"elapsedNs": elapsed_ns, "elapsedMs": elapsed_ns / 1_000_000,
|
|
652
|
+
"editorInputOrigin": {"row": editor_origin[0], "column": editor_origin[1]},
|
|
653
|
+
"beforeCell": before[editor_origin[0]][editor_origin[1]],
|
|
654
|
+
"afterCell": terminal.screen.cells[editor_origin[0]][editor_origin[1]],
|
|
655
|
+
"changedCellAtInputOrigin": True,
|
|
656
|
+
"observerWork": terminal.observer_delta(observer_before),
|
|
657
|
+
"timingScope": "keypress write through select/read/VT parse and changed-cell observation; not compositor presentation"}
|
|
658
|
+
if timed:
|
|
659
|
+
input_ms.append(elapsed_ns / 1_000_000)
|
|
660
|
+
input_observations.append(observation)
|
|
661
|
+
else:
|
|
662
|
+
nonlocal input_warmup_observation
|
|
663
|
+
input_warmup_observation = observation
|
|
664
|
+
terminal.send(b"\x7f")
|
|
665
|
+
terminal.collect_until(
|
|
666
|
+
lambda _output: terminal.screen.cells[editor_origin[0]][editor_origin[1]] == editor_empty_cell,
|
|
667
|
+
1, "editor restored to empty placeholder after backspace", poll_interval=0.001)
|
|
668
|
+
return True
|
|
669
|
+
if timed:
|
|
670
|
+
input_observations.append({"sample": sample_index, "key": marker, "status": "failed",
|
|
671
|
+
"startedAt": started_at, "failedAt": utc_now(),
|
|
672
|
+
"startedMonotonicNs": started_monotonic_ns,
|
|
673
|
+
"failedMonotonicNs": time.monotonic_ns(),
|
|
674
|
+
"failure": "TimeoutError: no changed cell at parsed editor input origin within 2 seconds",
|
|
675
|
+
"editorInputOrigin": {"row": editor_origin[0], "column": editor_origin[1]},
|
|
676
|
+
"beforeCell": before[editor_origin[0]][editor_origin[1]],
|
|
677
|
+
"afterCell": terminal.screen.cells[editor_origin[0]][editor_origin[1]],
|
|
678
|
+
"observerWork": terminal.observer_delta(observer_before)})
|
|
679
|
+
else:
|
|
680
|
+
input_warmup_observation = {"sample": 0, "key": marker, "status": "failed",
|
|
681
|
+
"startedAt": started_at, "failedAt": utc_now(),
|
|
682
|
+
"startedMonotonicNs": started_monotonic_ns,
|
|
683
|
+
"failedMonotonicNs": time.monotonic_ns(),
|
|
684
|
+
"failure": "TimeoutError: warmup key not observed at parsed editor input origin",
|
|
685
|
+
"editorInputOrigin": {"row": editor_origin[0], "column": editor_origin[1]},
|
|
686
|
+
"beforeCell": before[editor_origin[0]][editor_origin[1]],
|
|
687
|
+
"afterCell": terminal.screen.cells[editor_origin[0]][editor_origin[1]],
|
|
688
|
+
"observerWork": terminal.observer_delta(observer_before)}
|
|
689
|
+
# Attempt to restore the input field after an undetected echo;
|
|
690
|
+
# this cleanup is outside any successful sample clock.
|
|
691
|
+
terminal.send(b"\x7f")
|
|
692
|
+
terminal.collect_until(
|
|
693
|
+
lambda _output: terminal.screen.cells[editor_origin[0]][editor_origin[1]] == editor_empty_cell,
|
|
694
|
+
1, "editor restored to empty placeholder after failed key", poll_interval=0.001)
|
|
695
|
+
return False
|
|
696
|
+
|
|
697
|
+
# Warm the editor/input path once; only the following N key echoes count.
|
|
698
|
+
input_warmup_passed = send_and_verify_input("z", 0, False)
|
|
699
|
+
for index in range(args.samples):
|
|
700
|
+
try:
|
|
701
|
+
send_and_verify_input(chr(ord("a") + index % 26), index + 1, True)
|
|
702
|
+
except Exception as error:
|
|
703
|
+
if not any(item.get("sample") == index + 1 and item.get("status") == "failed"
|
|
704
|
+
for item in input_observations):
|
|
705
|
+
input_observations.append({"sample": index + 1, "status": "failed",
|
|
706
|
+
"failedAt": utc_now(), "failure": f"{type(error).__name__}: {error}"})
|
|
707
|
+
input_ms.append(None)
|
|
708
|
+
if not input_warmup_passed:
|
|
709
|
+
result_warmup_failure = "warmup key not verified at editor input origin"
|
|
710
|
+
else:
|
|
711
|
+
result_warmup_failure = None
|
|
712
|
+
input_editor_restored_verified = (
|
|
713
|
+
locate_text(terminal.screen, NATIVE_EDITOR_PLACEHOLDER) == editor_origin and
|
|
714
|
+
terminal.screen.cells[editor_origin[0]][editor_origin[1]] == editor_empty_cell)
|
|
715
|
+
workspace_render = {"status": "not-run", "timedSamplesRun": 0,
|
|
716
|
+
"targetMs": 300, "targetMet": False,
|
|
717
|
+
"failure": "native CTG fixture required"}
|
|
718
|
+
if fixture and fixture.get("pluginObservation", {}).get("observed") == "active":
|
|
719
|
+
# First Enter accepts slash autocomplete. Require the command to
|
|
720
|
+
# remain visible as staged editor text before starting the clock.
|
|
721
|
+
terminal.send(b"/ctg-workspace")
|
|
722
|
+
terminal.collect_until(lambda _output: terminal.screen.contains("/ctg-workspace"),
|
|
723
|
+
5, "submitted workspace command visible before Enter")
|
|
724
|
+
terminal.send(b"\r")
|
|
725
|
+
terminal.collect_until(lambda _output: workspace_command_staged(terminal.screen, editor_origin),
|
|
726
|
+
5, "selected workspace command after autocomplete dismissal")
|
|
727
|
+
before_workspace = terminal.screen.lines()
|
|
728
|
+
observer_before = terminal.observer_snapshot()
|
|
729
|
+
started_at = utc_now()
|
|
730
|
+
started = time.perf_counter_ns()
|
|
731
|
+
terminal.send(b"\r")
|
|
732
|
+
try:
|
|
733
|
+
terminal.collect_until(lambda _output: workspace_render_completed(terminal.screen, before_workspace),
|
|
734
|
+
15, "fresh rendered CTG workspace route and sessions section", poll_interval=0.001)
|
|
735
|
+
elapsed_ns = time.perf_counter_ns() - started
|
|
736
|
+
workspace_render = {"status": "complete", "timedSamplesRun": 1,
|
|
737
|
+
"startedAt": started_at, "completedAt": utc_now(),
|
|
738
|
+
"elapsedNs": elapsed_ns, "elapsedMs": elapsed_ns / 1_000_000,
|
|
739
|
+
"targetMs": 300, "targetMet": elapsed_ns / 1_000_000 <= 300,
|
|
740
|
+
"sentinels": list(WORKSPACE_RENDER_SENTINELS),
|
|
741
|
+
"observerWork": terminal.observer_delta(observer_before),
|
|
742
|
+
"timingScope": "submission Enter write through PTY select/read, VT parse and fresh parsed route/data render; autocomplete acceptance excluded; observer work is not subtracted; not compositor presentation"}
|
|
743
|
+
except Exception as error:
|
|
744
|
+
workspace_render = {"status": "failed", "timedSamplesRun": 0,
|
|
745
|
+
"startedAt": started_at, "failedAt": utc_now(),
|
|
746
|
+
"targetMs": 300, "targetMet": False,
|
|
747
|
+
"failure": f"{type(error).__name__}: {error}",
|
|
748
|
+
"sentinels": list(WORKSPACE_RENDER_SENTINELS),
|
|
749
|
+
"observerWork": terminal.observer_delta(observer_before),
|
|
750
|
+
"finalScreen": terminal.screen.lines()}
|
|
751
|
+
result["workspaceRender"] = workspace_render
|
|
752
|
+
if fixture:
|
|
753
|
+
host_samples = sampler.stop()
|
|
754
|
+
else:
|
|
755
|
+
host_samples = []
|
|
756
|
+
|
|
757
|
+
successful_input_ms = [value for value in input_ms if value is not None]
|
|
758
|
+
successful_switch_ms = [value for value in switch_ms if value is not None]
|
|
759
|
+
input_failed_samples = sum(item.get("status") == "failed" for item in input_observations)
|
|
760
|
+
result["input"] = {"samples": args.samples, "successfulSamples": len(successful_input_ms),
|
|
761
|
+
"failedSamples": input_failed_samples,
|
|
762
|
+
"p95Ms": round(percentile95(successful_input_ms), 3) if successful_input_ms else None,
|
|
763
|
+
"maxMs": round(max(successful_input_ms), 3) if successful_input_ms else None, "targetMs": TARGET_INPUT_MS,
|
|
764
|
+
"targetMet": (result_warmup_failure is None and len(successful_input_ms) == args.samples and input_failed_samples == 0 and
|
|
765
|
+
percentile95(successful_input_ms) <= TARGET_INPUT_MS),
|
|
766
|
+
"basis": "keypress write to changed cell at native editor placeholder origin",
|
|
767
|
+
"warmupSamplesExcluded": 1,
|
|
768
|
+
"warmupPassed": result_warmup_failure is None,
|
|
769
|
+
"warmupFailure": result_warmup_failure,
|
|
770
|
+
"warmupObservation": input_warmup_observation,
|
|
771
|
+
"editorInputOrigin": {"row": editor_origin[0], "column": editor_origin[1]},
|
|
772
|
+
"finalScreen": terminal.screen.lines(),
|
|
773
|
+
"finalEditorCell": terminal.screen.cells[editor_origin[0]][editor_origin[1]],
|
|
774
|
+
"finalEditorCellExpected": editor_empty_cell,
|
|
775
|
+
"finalEditorPlaceholderVerified": input_editor_restored_verified,
|
|
776
|
+
"rawSamples": input_observations}
|
|
777
|
+
switch_failed_samples = sum(item.get("status") == "failed" for item in switch_observations)
|
|
778
|
+
result["warmSessionSwitch"] = {"samples": args.samples if fixture else 0, "successfulSamples": len(successful_switch_ms),
|
|
779
|
+
"failedSamples": switch_failed_samples,
|
|
780
|
+
"p95Ms": round(percentile95(successful_switch_ms), 3) if successful_switch_ms else None,
|
|
781
|
+
"maxMs": round(max(successful_switch_ms), 3) if successful_switch_ms else None,
|
|
782
|
+
"targetMs": TARGET_SWITCH_MS,
|
|
783
|
+
"targetMet": percentile95(successful_switch_ms) <= TARGET_SWITCH_MS if len(successful_switch_ms) == args.samples and switch_failed_samples == 0 else False,
|
|
784
|
+
"note": switch_note,
|
|
785
|
+
"sequenceMs": [round(value, 3) if value is not None else None for value in switch_ms],
|
|
786
|
+
"rawSamples": switch_observations}
|
|
787
|
+
if fixture:
|
|
788
|
+
result["hostConditionsDuringMeasurement"] = host_samples
|
|
789
|
+
result["observerTotals"] = terminal.observer_snapshot()
|
|
790
|
+
# Bounded idle observation; report all bytes and RSS trend. Native status
|
|
791
|
+
# clocks may legitimately redraw, so byte activity alone is not a failure.
|
|
792
|
+
def process_tree_rss_kb(root_pid):
|
|
793
|
+
# Include plugin/runtime descendants when Linux exposes process stats.
|
|
794
|
+
try:
|
|
795
|
+
root_pid = int(root_pid)
|
|
796
|
+
if not pathlib.Path(f"/proc/{root_pid}/status").exists():
|
|
797
|
+
return None
|
|
798
|
+
parent = {root_pid}
|
|
799
|
+
rss_total = 0
|
|
800
|
+
while parent:
|
|
801
|
+
discovered = set()
|
|
802
|
+
for proc in pathlib.Path("/proc").iterdir():
|
|
803
|
+
if not proc.name.isdecimal():
|
|
804
|
+
continue
|
|
805
|
+
try:
|
|
806
|
+
status = (proc / "status").read_text()
|
|
807
|
+
ppid = int(status.split("PPid:")[1].split()[0])
|
|
808
|
+
if ppid in parent:
|
|
809
|
+
discovered.add(int(proc.name))
|
|
810
|
+
if int(proc.name) in parent:
|
|
811
|
+
rss_total += int(status.split("VmRSS:")[1].split()[0])
|
|
812
|
+
except (OSError, IndexError, ValueError):
|
|
813
|
+
continue
|
|
814
|
+
parent = discovered
|
|
815
|
+
return rss_total
|
|
816
|
+
except OSError:
|
|
817
|
+
return None
|
|
818
|
+
idle_start_bytes = len(terminal.transcript)
|
|
819
|
+
rss = []
|
|
820
|
+
tui_rss = []
|
|
821
|
+
server_rss = []
|
|
822
|
+
idle_deadline = time.monotonic() + args.idle_seconds
|
|
823
|
+
while time.monotonic() < idle_deadline:
|
|
824
|
+
terminal.drain(0.1)
|
|
825
|
+
tui_sample = process_tree_rss_kb(child.pid)
|
|
826
|
+
server_pid = fixture.get("serverPid") if fixture else None
|
|
827
|
+
server_sample = process_tree_rss_kb(server_pid) if server_pid else None
|
|
828
|
+
sample = ((tui_sample or 0) + (server_sample or 0)
|
|
829
|
+
if tui_sample is not None or server_sample is not None else None)
|
|
830
|
+
if sample is not None:
|
|
831
|
+
rss.append(sample)
|
|
832
|
+
if tui_sample is not None:
|
|
833
|
+
tui_rss.append(tui_sample)
|
|
834
|
+
if server_sample is not None:
|
|
835
|
+
server_rss.append(server_sample)
|
|
836
|
+
idle_bytes = len(terminal.transcript) - idle_start_bytes
|
|
837
|
+
growth_mb = ((max(rss) - min(rss)) / 1024) if rss else None
|
|
838
|
+
result["idle"] = {"windowSeconds": args.idle_seconds, "ptyBytes": idle_bytes,
|
|
839
|
+
"byteBudget": args.idle_byte_limit, "byteBudgetMet": idle_bytes <= args.idle_byte_limit,
|
|
840
|
+
"rssSamples": len(rss), "rssMinMb": round(min(rss) / 1024, 2) if rss else None,
|
|
841
|
+
"rssMaxMb": round(max(rss) / 1024, 2) if rss else None,
|
|
842
|
+
"rssGrowthMb": round(growth_mb, 2) if growth_mb is not None else None,
|
|
843
|
+
"tuiProcessTreeRssMinMb": round(min(tui_rss) / 1024, 2) if tui_rss else None,
|
|
844
|
+
"tuiProcessTreeRssMaxMb": round(max(tui_rss) / 1024, 2) if tui_rss else None,
|
|
845
|
+
"ownedServerProcessTreeRssMinMb": round(min(server_rss) / 1024, 2) if server_rss else None,
|
|
846
|
+
"ownedServerProcessTreeRssMaxMb": round(max(server_rss) / 1024, 2) if server_rss else None,
|
|
847
|
+
"serverPid": fixture.get("serverPid") if fixture else None,
|
|
848
|
+
"rssGrowthBudgetMb": args.memory_growth_limit_mb,
|
|
849
|
+
"rssGrowthBudgetMet": (growth_mb <= args.memory_growth_limit_mb
|
|
850
|
+
if growth_mb is not None and args.memory_growth_limit_mb is not None else None),
|
|
851
|
+
"rssScope": "native process plus descendants where /proc is available",
|
|
852
|
+
"acceptance": "diagnostic only; no product-defined sustained-memory ceiling exists",
|
|
853
|
+
"interpretation": "PTY bytes and short-window RSS are observations only; native status redraws are not assumed plugin defects"}
|
|
854
|
+
result["hostConditionsAtRunEnd"] = host_conditions()
|
|
855
|
+
result["overallTargetsMet"] = bool(result["input"]["targetMet"] and
|
|
856
|
+
result["warmSessionSwitch"]["targetMet"] is True and
|
|
857
|
+
result["input"]["finalEditorPlaceholderVerified"] and
|
|
858
|
+
(not fixture or fixture.get("pluginObservation", {}).get("observed") != "active" or
|
|
859
|
+
result["workspaceRender"]["targetMet"] is True))
|
|
860
|
+
text = json.dumps(result, indent=2)
|
|
861
|
+
print(text)
|
|
862
|
+
if args.report:
|
|
863
|
+
pathlib.Path(args.report).write_text(text + "\n")
|
|
864
|
+
if not result["overallTargetsMet"]:
|
|
865
|
+
raise SystemExit(1)
|
|
866
|
+
except BaseException as error:
|
|
867
|
+
if args.report:
|
|
868
|
+
result["runStatus"] = "failed"
|
|
869
|
+
result["runFailure"] = f"{type(error).__name__}: {error}"
|
|
870
|
+
pathlib.Path(args.report).write_text(json.dumps(result, indent=2) + "\n")
|
|
871
|
+
raise
|
|
872
|
+
finally:
|
|
873
|
+
signal.signal(signal.SIGTERM, previous_sigterm)
|
|
874
|
+
if child.poll() is None:
|
|
875
|
+
os.killpg(child.pid, signal.SIGTERM)
|
|
876
|
+
try:
|
|
877
|
+
child.wait(timeout=5)
|
|
878
|
+
except subprocess.TimeoutExpired:
|
|
879
|
+
os.killpg(child.pid, signal.SIGKILL)
|
|
880
|
+
child.wait()
|
|
881
|
+
os.close(master)
|
|
882
|
+
if owns_root:
|
|
883
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
if __name__ == "__main__":
|
|
887
|
+
main()
|