@nklisch/pi-agile-workflow 0.15.3
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/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +28 -0
- package/CHANGELOG.md +591 -0
- package/README.md +107 -0
- package/docs/ARCHITECTURE.md +716 -0
- package/docs/MIGRATION.md +419 -0
- package/docs/PRINCIPLES.md +464 -0
- package/docs/ROADMAP.md +95 -0
- package/docs/SPEC.md +855 -0
- package/docs/VISION.md +106 -0
- package/extensions/agile-workflow.test.ts +419 -0
- package/extensions/agile-workflow.ts +467 -0
- package/hooks/hooks.json +52 -0
- package/hooks/scripts/prompt-context.py +628 -0
- package/hooks/scripts/substrate-maintainer.py +359 -0
- package/hooks/scripts/test_prompt_context.py +757 -0
- package/package.json +30 -0
- package/scripts/install-work-view.sh +165 -0
- package/scripts/tests/agent-metadata.test.sh +75 -0
- package/scripts/tests/bump-version.test.sh +552 -0
- package/scripts/tests/channel-parity.test.sh +117 -0
- package/scripts/tests/convert-content-integrity.test.sh +246 -0
- package/scripts/tests/convert-install-routing.test.sh +159 -0
- package/scripts/tests/install-work-view.test.sh +411 -0
- package/scripts/tests/pi-package-metadata.test.sh +119 -0
- package/scripts/tests/work-board-shim.test.sh +239 -0
- package/scripts/tests/work-view-dist-version.test.sh +105 -0
- package/scripts/work-board.sh +64 -0
- package/scripts/work-view.sh +423 -0
- package/skills/autopilot/SKILL.md +330 -0
- package/skills/autopilot/agents/openai.yaml +6 -0
- package/skills/board/SKILL.md +62 -0
- package/skills/board/agents/openai.yaml +6 -0
- package/skills/bold-refactor/SKILL.md +244 -0
- package/skills/bold-refactor/agents/openai.yaml +6 -0
- package/skills/bug-scan/SKILL.md +378 -0
- package/skills/bug-scan/agents/openai.yaml +6 -0
- package/skills/bug-scan/references/async-promises.md +119 -0
- package/skills/bug-scan/references/concurrency-races.md +148 -0
- package/skills/bug-scan/references/data-layer.md +167 -0
- package/skills/bug-scan/references/error-handling.md +197 -0
- package/skills/bug-scan/references/gate-item-template.md +94 -0
- package/skills/bug-scan/references/language-footguns.md +219 -0
- package/skills/bug-scan/references/parked-item-template.md +103 -0
- package/skills/bug-scan/references/report-template.md +115 -0
- package/skills/bug-scan/references/resource-leaks.md +199 -0
- package/skills/bug-scan/references/state-closures.md +148 -0
- package/skills/bug-scan/references/time-numbers.md +158 -0
- package/skills/convert/SKILL.md +1463 -0
- package/skills/convert/agents/openai.yaml +6 -0
- package/skills/convert/references/legacy-overlap-migration.md +179 -0
- package/skills/deep-code-scan/SKILL.md +378 -0
- package/skills/deep-code-scan/agents/openai.yaml +6 -0
- package/skills/deep-code-scan/references/artifact-ledger.md +148 -0
- package/skills/deep-code-scan/references/consolidation.md +91 -0
- package/skills/deep-code-scan/references/decomposition.md +127 -0
- package/skills/deep-code-scan/references/item-templates.md +263 -0
- package/skills/deep-code-scan/references/lane-catalog.md +129 -0
- package/skills/deep-code-scan/references/review-gauntlet.md +108 -0
- package/skills/deep-code-scan/references/scanner-brief.md +152 -0
- package/skills/e2e-test-design/SKILL.md +517 -0
- package/skills/e2e-test-design/references/anti-tautology.md +261 -0
- package/skills/e2e-test-design/references/service-mocks.md +234 -0
- package/skills/e2e-test-design/references/test-taxonomy.md +211 -0
- package/skills/epic-design/SKILL.md +476 -0
- package/skills/epicize/SKILL.md +210 -0
- package/skills/epicize/agents/openai.yaml +6 -0
- package/skills/feature-design/SKILL.md +472 -0
- package/skills/fix/SKILL.md +173 -0
- package/skills/gate-cruft/SKILL.md +258 -0
- package/skills/gate-docs/SKILL.md +278 -0
- package/skills/gate-patterns/SKILL.md +389 -0
- package/skills/gate-refactor/SKILL.md +408 -0
- package/skills/gate-security/SKILL.md +275 -0
- package/skills/gate-tests/SKILL.md +367 -0
- package/skills/groom/SKILL.md +162 -0
- package/skills/groom/agents/openai.yaml +6 -0
- package/skills/ideate/SKILL.md +185 -0
- package/skills/ideate/agents/openai.yaml +6 -0
- package/skills/implement/SKILL.md +288 -0
- package/skills/implement-orchestrator/SKILL.md +604 -0
- package/skills/park/SKILL.md +111 -0
- package/skills/perf-design/SKILL.md +427 -0
- package/skills/perf-scout/SKILL.md +404 -0
- package/skills/perf-scout/agents/openai.yaml +6 -0
- package/skills/perf-scout/references/algorithmic-and-data-structures.md +126 -0
- package/skills/perf-scout/references/approximation-and-precomputation.md +136 -0
- package/skills/perf-scout/references/caching-and-memoization.md +123 -0
- package/skills/perf-scout/references/compiler-runtime-and-language.md +136 -0
- package/skills/perf-scout/references/database-and-storage-internals.md +144 -0
- package/skills/perf-scout/references/distributed-systems.md +150 -0
- package/skills/perf-scout/references/game-engine-and-realtime.md +136 -0
- package/skills/perf-scout/references/gpu-and-accelerators.md +130 -0
- package/skills/perf-scout/references/idea-ranking.md +75 -0
- package/skills/perf-scout/references/io-and-batching.md +135 -0
- package/skills/perf-scout/references/memory-and-data-locality.md +137 -0
- package/skills/perf-scout/references/parallelism-and-vectorization.md +126 -0
- package/skills/perf-scout/references/parked-item-template.md +117 -0
- package/skills/perf-scout/references/peer-review-pass.md +150 -0
- package/skills/perf-scout/references/report-template.md +148 -0
- package/skills/principles/SKILL.md +678 -0
- package/skills/principles/references/models.md +191 -0
- package/skills/principles/references/subagents.md +158 -0
- package/skills/prose-author/SKILL.md +185 -0
- package/skills/refactor-conventions-creator/SKILL.md +359 -0
- package/skills/refactor-conventions-creator/agents/openai.yaml +6 -0
- package/skills/refactor-conventions-creator/references/common-structures.md +91 -0
- package/skills/refactor-conventions-creator/references/common-styles.md +90 -0
- package/skills/refactor-design/SKILL.md +384 -0
- package/skills/release-deploy/SKILL.md +667 -0
- package/skills/release-deploy/agents/openai.yaml +6 -0
- package/skills/research/SKILL.md +166 -0
- package/skills/research/agents/openai.yaml +6 -0
- package/skills/review/SKILL.md +267 -0
- package/skills/review/references/deep-review.md +98 -0
- package/skills/review/references/review-lenses.md +68 -0
- package/skills/review/references/substrate-side-effects.md +161 -0
- package/skills/review/references/target-resolution.md +52 -0
- package/skills/scope/SKILL.md +486 -0
- package/work-view/Cargo.lock +486 -0
- package/work-view/Cargo.toml +9 -0
- package/work-view/crates/cli/.work-view-version +1 -0
- package/work-view/crates/cli/Cargo.toml +18 -0
- package/work-view/crates/cli/src/actionable.rs +500 -0
- package/work-view/crates/cli/src/args.rs +792 -0
- package/work-view/crates/cli/src/board/assets/board.css +855 -0
- package/work-view/crates/cli/src/board/assets/board.js +259 -0
- package/work-view/crates/cli/src/board/assets/card.js +139 -0
- package/work-view/crates/cli/src/board/assets/components.css +290 -0
- package/work-view/crates/cli/src/board/assets/dependency.js +1193 -0
- package/work-view/crates/cli/src/board/assets/detail.js +256 -0
- package/work-view/crates/cli/src/board/assets/filters.js +389 -0
- package/work-view/crates/cli/src/board/assets/index.html +77 -0
- package/work-view/crates/cli/src/board/assets/kanban.js +231 -0
- package/work-view/crates/cli/src/board/assets/markdown.js +260 -0
- package/work-view/crates/cli/src/board/assets/motion.css +131 -0
- package/work-view/crates/cli/src/board/assets/state.js +245 -0
- package/work-view/crates/cli/src/board/assets/table.js +304 -0
- package/work-view/crates/cli/src/board/assets/tokens.css +602 -0
- package/work-view/crates/cli/src/board/assets/views.js +41 -0
- package/work-view/crates/cli/src/board/assets.rs +162 -0
- package/work-view/crates/cli/src/board/feed.rs +204 -0
- package/work-view/crates/cli/src/board/mod.rs +246 -0
- package/work-view/crates/cli/src/board/open.rs +144 -0
- package/work-view/crates/cli/src/board/server.rs +407 -0
- package/work-view/crates/cli/src/main.rs +195 -0
- package/work-view/crates/cli/src/render.rs +354 -0
- package/work-view/crates/cli/src/scope.rs +157 -0
- package/work-view/crates/cli/src/stale.rs +581 -0
- package/work-view/crates/cli/tests/board-js/dependency-table.test.mjs +64 -0
- package/work-view/crates/cli/tests/board-js/expanded-browsing.test.mjs +79 -0
- package/work-view/crates/cli/tests/board-js/harness-smoke.test.mjs +11 -0
- package/work-view/crates/cli/tests/board-js/harness.mjs +437 -0
- package/work-view/crates/cli/tests/board-js/kanban-detail.test.mjs +75 -0
- package/work-view/crates/cli/tests/board-js/markdown-filter.test.mjs +60 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/CONVENTIONS.md +3 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/epics/epic-alpha.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-a.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-b.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-alpha-1.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-research-1.md +19 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-done.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-shipped.md +18 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/backlog/idea-backlog.md +7 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/CONVENTIONS.md +6 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-dep-done.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-design-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-impl-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/stories/story-review-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/CONVENTIONS.md +4 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/epics/malformed-no-closing-fence.md +8 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/features/good-item.md +16 -0
- package/work-view/crates/cli/tests/integration.rs +2833 -0
- package/work-view/crates/core/Cargo.toml +13 -0
- package/work-view/crates/core/src/error.rs +61 -0
- package/work-view/crates/core/src/filter.rs +804 -0
- package/work-view/crates/core/src/graph.rs +324 -0
- package/work-view/crates/core/src/index.rs +590 -0
- package/work-view/crates/core/src/lib.rs +54 -0
- package/work-view/crates/core/src/model.rs +202 -0
- package/work-view/crates/core/src/parse.rs +494 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/CONVENTIONS.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/epics/epic-core.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-parser.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-query.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/malformed-no-id.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-parse-unit.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-security-gate.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-old.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-parser.md +17 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/backlog/idea-future-board.md +7 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/CONVENTIONS.md +10 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/archive/dup-item.md +18 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/releases/v2.0/dup-item.md +18 -0
- package/work-view/crates/core/tests/integration.rs +541 -0
- package/work-view/dist/.gitattributes +1 -0
- package/work-view/dist/README.md +42 -0
- package/work-view/dist/aarch64-apple-darwin/work-view +0 -0
- package/work-view/dist/aarch64-unknown-linux-musl/work-view +0 -0
- package/work-view/dist/x86_64-apple-darwin/work-view +0 -0
- package/work-view/dist/x86_64-unknown-linux-musl/work-view +0 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stdlib unittest suite for prompt-context.py.
|
|
3
|
+
|
|
4
|
+
Pure stdlib (unittest + unittest.mock) — does NOT depend on pytest. Run with:
|
|
5
|
+
|
|
6
|
+
cd plugins/agile-workflow/hooks/scripts
|
|
7
|
+
python3 -m unittest test_prompt_context -v
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
import unittest
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from unittest import mock
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Import prompt-context.py by path (filename has a hyphen, so it is not a
|
|
26
|
+
# normal importable module name).
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
_MODULE_PATH = Path(__file__).resolve().parent / "prompt-context.py"
|
|
29
|
+
_spec = importlib.util.spec_from_file_location("prompt_context", _MODULE_PATH)
|
|
30
|
+
assert _spec is not None and _spec.loader is not None
|
|
31
|
+
prompt_context = importlib.util.module_from_spec(_spec)
|
|
32
|
+
_spec.loader.exec_module(prompt_context)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WorkViewSelfHealTest(unittest.TestCase):
|
|
36
|
+
"""Guards the hook's fail-open work-view installer self-heal step."""
|
|
37
|
+
|
|
38
|
+
def setUp(self) -> None:
|
|
39
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
40
|
+
self.root = Path(self._tmp.name) / "workspace"
|
|
41
|
+
self.root.mkdir()
|
|
42
|
+
(self.root / ".work").mkdir()
|
|
43
|
+
(self.root / ".work" / "CONVENTIONS.md").write_text("# Conventions\n", encoding="utf-8")
|
|
44
|
+
self.plugin = Path(self._tmp.name) / "plugin"
|
|
45
|
+
(self.plugin / ".claude-plugin").mkdir(parents=True)
|
|
46
|
+
(self.plugin / ".claude-plugin" / "plugin.json").write_text(
|
|
47
|
+
'{"version": "0.8.7"}\n', encoding="utf-8"
|
|
48
|
+
)
|
|
49
|
+
(self.plugin / "scripts").mkdir()
|
|
50
|
+
(self.plugin / "scripts" / "install-work-view.sh").write_text(
|
|
51
|
+
"#!/usr/bin/env bash\nexit 0\n", encoding="utf-8"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def tearDown(self) -> None:
|
|
55
|
+
self._tmp.cleanup()
|
|
56
|
+
|
|
57
|
+
def _write_work_view(self) -> Path:
|
|
58
|
+
work_view = self.root / ".work" / "bin" / "work-view"
|
|
59
|
+
work_view.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
work_view.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
|
61
|
+
work_view.chmod(0o755)
|
|
62
|
+
return work_view
|
|
63
|
+
|
|
64
|
+
def _env(self):
|
|
65
|
+
return mock.patch.dict(
|
|
66
|
+
prompt_context.os.environ,
|
|
67
|
+
{"PLUGIN_ROOT": str(self.plugin)},
|
|
68
|
+
clear=True,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def test_sessionstart_stale_copy_runs_installer(self) -> None:
|
|
72
|
+
self._write_work_view()
|
|
73
|
+
with self._env(), mock.patch.object(
|
|
74
|
+
prompt_context, "installed_version", return_value="0.8.6"
|
|
75
|
+
), mock.patch.object(prompt_context, "run_installer") as run_installer:
|
|
76
|
+
prompt_context.self_heal_work_view(self.root, "SessionStart")
|
|
77
|
+
|
|
78
|
+
run_installer.assert_called_once_with(self.root, self.plugin)
|
|
79
|
+
|
|
80
|
+
def test_sessionstart_unrecognized_copy_runs_installer(self) -> None:
|
|
81
|
+
self._write_work_view()
|
|
82
|
+
with self._env(), mock.patch.object(
|
|
83
|
+
prompt_context, "installed_version", return_value=None
|
|
84
|
+
), mock.patch.object(prompt_context, "run_installer") as run_installer:
|
|
85
|
+
prompt_context.self_heal_work_view(self.root, "SessionStart")
|
|
86
|
+
|
|
87
|
+
run_installer.assert_called_once_with(self.root, self.plugin)
|
|
88
|
+
|
|
89
|
+
def test_sessionstart_current_copy_does_not_install(self) -> None:
|
|
90
|
+
self._write_work_view()
|
|
91
|
+
with self._env(), mock.patch.object(
|
|
92
|
+
prompt_context, "installed_version", return_value="0.8.7"
|
|
93
|
+
), mock.patch.object(prompt_context, "run_installer") as run_installer:
|
|
94
|
+
prompt_context.self_heal_work_view(self.root, "SessionStart")
|
|
95
|
+
|
|
96
|
+
run_installer.assert_not_called()
|
|
97
|
+
|
|
98
|
+
def test_sessionstart_missing_copy_installs_without_version_probe(self) -> None:
|
|
99
|
+
with self._env(), mock.patch.object(
|
|
100
|
+
prompt_context, "installed_version", side_effect=AssertionError("no probe")
|
|
101
|
+
), mock.patch.object(prompt_context, "run_installer") as run_installer:
|
|
102
|
+
prompt_context.self_heal_work_view(self.root, "SessionStart")
|
|
103
|
+
|
|
104
|
+
run_installer.assert_called_once_with(self.root, self.plugin)
|
|
105
|
+
|
|
106
|
+
def test_userpromptsubmit_missing_installs(self) -> None:
|
|
107
|
+
with self._env(), mock.patch.object(
|
|
108
|
+
prompt_context, "installed_version", side_effect=AssertionError("no probe")
|
|
109
|
+
), mock.patch.object(prompt_context, "run_installer") as run_installer:
|
|
110
|
+
prompt_context.self_heal_work_view(self.root, "UserPromptSubmit")
|
|
111
|
+
|
|
112
|
+
run_installer.assert_called_once_with(self.root, self.plugin)
|
|
113
|
+
|
|
114
|
+
def test_userpromptsubmit_present_does_not_install_or_probe_version(self) -> None:
|
|
115
|
+
self._write_work_view()
|
|
116
|
+
with self._env(), mock.patch.object(
|
|
117
|
+
prompt_context, "installed_version", side_effect=AssertionError("no version probe")
|
|
118
|
+
) as installed_version, mock.patch.object(
|
|
119
|
+
prompt_context.subprocess, "run", side_effect=AssertionError("no subprocess")
|
|
120
|
+
), mock.patch.object(prompt_context, "run_installer") as run_installer:
|
|
121
|
+
prompt_context.self_heal_work_view(self.root, "UserPromptSubmit")
|
|
122
|
+
|
|
123
|
+
installed_version.assert_not_called()
|
|
124
|
+
run_installer.assert_not_called()
|
|
125
|
+
|
|
126
|
+
def test_no_plugin_root_env_is_noop(self) -> None:
|
|
127
|
+
self._write_work_view()
|
|
128
|
+
with mock.patch.dict(prompt_context.os.environ, {}, clear=True), mock.patch.object(
|
|
129
|
+
prompt_context, "run_installer"
|
|
130
|
+
) as run_installer:
|
|
131
|
+
prompt_context.self_heal_work_view(self.root, "SessionStart")
|
|
132
|
+
|
|
133
|
+
run_installer.assert_not_called()
|
|
134
|
+
|
|
135
|
+
def test_installed_version_uses_last_token(self) -> None:
|
|
136
|
+
self._write_work_view()
|
|
137
|
+
completed = mock.Mock(returncode=0, stdout="work-view 0.8.7\n")
|
|
138
|
+
with mock.patch.object(prompt_context.subprocess, "run", return_value=completed):
|
|
139
|
+
self.assertEqual(prompt_context.installed_version(self.root), "0.8.7")
|
|
140
|
+
|
|
141
|
+
def test_installed_version_rejects_unrecognized_output(self) -> None:
|
|
142
|
+
self._write_work_view()
|
|
143
|
+
completed = mock.Mock(returncode=0, stdout="version 0.8.7\n")
|
|
144
|
+
with mock.patch.object(prompt_context.subprocess, "run", return_value=completed):
|
|
145
|
+
self.assertIsNone(prompt_context.installed_version(self.root))
|
|
146
|
+
|
|
147
|
+
def test_installed_version_returns_none_on_probe_timeout(self) -> None:
|
|
148
|
+
"""A --version probe that times out yields an unknown (None) version.
|
|
149
|
+
|
|
150
|
+
Guards the distinct failure from a broken installer: the probe itself
|
|
151
|
+
runs a subprocess on a possibly-broken work-view (corrupt binary, bash
|
|
152
|
+
entrypoint stuck on a prompt). If that subprocess raises TimeoutExpired,
|
|
153
|
+
installed_version must swallow it and report None so the copy is treated
|
|
154
|
+
as stale/unknown rather than crashing the hook.
|
|
155
|
+
"""
|
|
156
|
+
self._write_work_view()
|
|
157
|
+
timeout = prompt_context.subprocess.TimeoutExpired(cmd="work-view --version", timeout=5)
|
|
158
|
+
with mock.patch.object(prompt_context.subprocess, "run", side_effect=timeout):
|
|
159
|
+
self.assertIsNone(prompt_context.installed_version(self.root))
|
|
160
|
+
|
|
161
|
+
def test_sessionstart_version_probe_timeout_installs(self) -> None:
|
|
162
|
+
"""SessionStart + a hung/timed-out --version probe -> treat as stale.
|
|
163
|
+
|
|
164
|
+
The probe subprocess raises TimeoutExpired. self_heal_work_view must not
|
|
165
|
+
raise, and because the version is now unknown (None != want) it must run
|
|
166
|
+
the installer to repair the broken copy.
|
|
167
|
+
"""
|
|
168
|
+
self._write_work_view()
|
|
169
|
+
timeout = prompt_context.subprocess.TimeoutExpired(cmd="work-view --version", timeout=5)
|
|
170
|
+
with self._env(), mock.patch.object(
|
|
171
|
+
prompt_context.subprocess, "run", side_effect=timeout
|
|
172
|
+
), mock.patch.object(prompt_context, "run_installer") as run_installer:
|
|
173
|
+
# Must complete without raising even though the probe times out.
|
|
174
|
+
prompt_context.self_heal_work_view(self.root, "SessionStart")
|
|
175
|
+
|
|
176
|
+
run_installer.assert_called_once_with(self.root, self.plugin)
|
|
177
|
+
|
|
178
|
+
def test_main_failopens_when_version_probe_times_out(self) -> None:
|
|
179
|
+
"""SessionStart hook stays fail-open when the --version probe times out.
|
|
180
|
+
|
|
181
|
+
End-to-end through main(): the real installed_version runs but its
|
|
182
|
+
subprocess.run raises TimeoutExpired. The hook must still exit 0 and emit
|
|
183
|
+
its normal .agents/rules/ context. run_installer is stubbed (the copy is
|
|
184
|
+
treated as stale, so it would be invoked) to keep the test hermetic.
|
|
185
|
+
"""
|
|
186
|
+
rules_dir = self.root / ".agents" / "rules"
|
|
187
|
+
rules_dir.mkdir(parents=True)
|
|
188
|
+
(rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
|
|
189
|
+
state_file = self.root / "hook-state.json"
|
|
190
|
+
self._write_work_view()
|
|
191
|
+
payload = {
|
|
192
|
+
"session_id": "s1",
|
|
193
|
+
"hook_event_name": "SessionStart",
|
|
194
|
+
"source": "startup",
|
|
195
|
+
"cwd": str(self.root),
|
|
196
|
+
}
|
|
197
|
+
timeout = prompt_context.subprocess.TimeoutExpired(cmd="work-view --version", timeout=5)
|
|
198
|
+
out = io.StringIO()
|
|
199
|
+
with self._env(), mock.patch.object(
|
|
200
|
+
prompt_context, "state_path", return_value=state_file
|
|
201
|
+
), mock.patch.object(
|
|
202
|
+
prompt_context.subprocess, "run", side_effect=timeout
|
|
203
|
+
), mock.patch.object(prompt_context, "run_installer") as run_installer, mock.patch.object(
|
|
204
|
+
prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
|
|
205
|
+
), mock.patch.object(prompt_context.sys, "stdout", out):
|
|
206
|
+
rc = prompt_context.main()
|
|
207
|
+
|
|
208
|
+
self.assertEqual(rc, 0)
|
|
209
|
+
printed = out.getvalue()
|
|
210
|
+
self.assertIn("additionalContext", printed)
|
|
211
|
+
self.assertIn("Rule A body", printed)
|
|
212
|
+
# Probe timed out -> version unknown -> copy treated as stale -> installer
|
|
213
|
+
# invoked. The hook never raised.
|
|
214
|
+
run_installer.assert_called_once_with(self.root, self.plugin)
|
|
215
|
+
|
|
216
|
+
def test_run_installer_suppresses_output(self) -> None:
|
|
217
|
+
with self._env(), mock.patch.object(prompt_context.subprocess, "run") as run:
|
|
218
|
+
prompt_context.run_installer(self.root, self.plugin)
|
|
219
|
+
|
|
220
|
+
kwargs = run.call_args.kwargs
|
|
221
|
+
self.assertEqual(kwargs["stdout"], prompt_context.subprocess.DEVNULL)
|
|
222
|
+
self.assertEqual(kwargs["stderr"], prompt_context.subprocess.DEVNULL)
|
|
223
|
+
self.assertEqual(kwargs["env"]["PLUGIN_ROOT"], str(self.plugin))
|
|
224
|
+
|
|
225
|
+
def test_main_failopens_when_installer_path_raises(self) -> None:
|
|
226
|
+
rules_dir = self.root / ".agents" / "rules"
|
|
227
|
+
rules_dir.mkdir(parents=True)
|
|
228
|
+
(rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
|
|
229
|
+
state_file = self.root / "hook-state.json"
|
|
230
|
+
payload = {
|
|
231
|
+
"session_id": "s1",
|
|
232
|
+
"hook_event_name": "SessionStart",
|
|
233
|
+
"source": "startup",
|
|
234
|
+
"cwd": str(self.root),
|
|
235
|
+
}
|
|
236
|
+
out = io.StringIO()
|
|
237
|
+
with self._env(), mock.patch.object(
|
|
238
|
+
prompt_context, "state_path", return_value=state_file
|
|
239
|
+
), mock.patch.object(
|
|
240
|
+
prompt_context, "run_installer", side_effect=RuntimeError("installer failed")
|
|
241
|
+
), mock.patch.object(
|
|
242
|
+
prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
|
|
243
|
+
), mock.patch.object(prompt_context.sys, "stdout", out):
|
|
244
|
+
rc = prompt_context.main()
|
|
245
|
+
|
|
246
|
+
self.assertEqual(rc, 0)
|
|
247
|
+
printed = out.getvalue()
|
|
248
|
+
self.assertIn("additionalContext", printed)
|
|
249
|
+
self.assertIn("Rule A body", printed)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class RulesLoaderTest(unittest.TestCase):
|
|
253
|
+
"""Guards the generic .agents/rules/ hook loader.
|
|
254
|
+
|
|
255
|
+
Covers the reader + hash, the CONVENTIONS flag/byte-cap, per-epoch +
|
|
256
|
+
content-hash dedup, and the SessionStart/PostCompact wiring. State is
|
|
257
|
+
isolated to a temp file so dedup does not leak across tests.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def setUp(self) -> None:
|
|
261
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
262
|
+
self.root = Path(self._tmp.name)
|
|
263
|
+
(self.root / ".work").mkdir()
|
|
264
|
+
self._write_conventions("# Conventions\n")
|
|
265
|
+
self.rules_dir = self.root / ".agents" / "rules"
|
|
266
|
+
self.rules_dir.mkdir(parents=True)
|
|
267
|
+
self.state_file = self.root / "hook-state.json"
|
|
268
|
+
self._state_patch = mock.patch.object(
|
|
269
|
+
prompt_context, "state_path", return_value=self.state_file
|
|
270
|
+
)
|
|
271
|
+
self._state_patch.start()
|
|
272
|
+
|
|
273
|
+
def tearDown(self) -> None:
|
|
274
|
+
self._state_patch.stop()
|
|
275
|
+
self._tmp.cleanup()
|
|
276
|
+
|
|
277
|
+
def _write_conventions(self, text: str) -> None:
|
|
278
|
+
(self.root / ".work" / "CONVENTIONS.md").write_text(text, encoding="utf-8")
|
|
279
|
+
|
|
280
|
+
def _payload(self, **kw):
|
|
281
|
+
base = {"session_id": "s1", "hook_event_name": "UserPromptSubmit"}
|
|
282
|
+
base.update(kw)
|
|
283
|
+
return base
|
|
284
|
+
|
|
285
|
+
def test_reads_and_hashes(self) -> None:
|
|
286
|
+
(self.rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
|
|
287
|
+
(self.rules_dir / "b.md").write_text("Rule B body", encoding="utf-8")
|
|
288
|
+
text, digest = prompt_context.read_rules_dir(self.root, 12000)
|
|
289
|
+
self.assertIn("## Project Rules", text)
|
|
290
|
+
self.assertIn("Rule A body", text)
|
|
291
|
+
self.assertIn("Rule B body", text)
|
|
292
|
+
self.assertTrue(digest)
|
|
293
|
+
|
|
294
|
+
def test_absent_dir_returns_empty(self) -> None:
|
|
295
|
+
shutil.rmtree(self.rules_dir)
|
|
296
|
+
self.assertEqual(prompt_context.read_rules_dir(self.root, 12000), ("", ""))
|
|
297
|
+
|
|
298
|
+
def test_empty_dir_returns_empty(self) -> None:
|
|
299
|
+
self.assertEqual(prompt_context.read_rules_dir(self.root, 12000), ("", ""))
|
|
300
|
+
|
|
301
|
+
def test_byte_cap_truncates(self) -> None:
|
|
302
|
+
(self.rules_dir / "big.md").write_text("x" * 5000, encoding="utf-8")
|
|
303
|
+
text, _ = prompt_context.read_rules_dir(self.root, 1000)
|
|
304
|
+
self.assertIn("truncated", text)
|
|
305
|
+
self.assertLess(len(text.encode("utf-8")), 2000)
|
|
306
|
+
|
|
307
|
+
def test_flag_off(self) -> None:
|
|
308
|
+
self._write_conventions("# Conventions\nrules_context: off\n")
|
|
309
|
+
enabled, _ = prompt_context.rules_config(self.root)
|
|
310
|
+
self.assertFalse(enabled)
|
|
311
|
+
|
|
312
|
+
def test_flag_default_on_and_max_bytes(self) -> None:
|
|
313
|
+
self._write_conventions("rules_context_max_bytes: 2048\n")
|
|
314
|
+
enabled, max_bytes = prompt_context.rules_config(self.root)
|
|
315
|
+
self.assertTrue(enabled)
|
|
316
|
+
self.assertEqual(max_bytes, 2048)
|
|
317
|
+
|
|
318
|
+
def test_emit_dedup_per_epoch(self) -> None:
|
|
319
|
+
(self.rules_dir / "a.md").write_text("Rule A", encoding="utf-8")
|
|
320
|
+
payload = self._payload()
|
|
321
|
+
first = prompt_context.emit_rules(self.root, payload)
|
|
322
|
+
self.assertIn("Rule A", first)
|
|
323
|
+
second = prompt_context.emit_rules(self.root, payload)
|
|
324
|
+
self.assertEqual(second, "")
|
|
325
|
+
|
|
326
|
+
def test_emit_force_bypasses_dedup_for_pi_rebuilt_prompt(self) -> None:
|
|
327
|
+
(self.rules_dir / "a.md").write_text("Rule A", encoding="utf-8")
|
|
328
|
+
payload = self._payload()
|
|
329
|
+
self.assertIn("Rule A", prompt_context.emit_rules(self.root, payload, force=True))
|
|
330
|
+
self.assertIn("Rule A", prompt_context.emit_rules(self.root, payload, force=True))
|
|
331
|
+
|
|
332
|
+
def test_emit_reinject_on_content_change(self) -> None:
|
|
333
|
+
(self.rules_dir / "a.md").write_text("Rule A", encoding="utf-8")
|
|
334
|
+
payload = self._payload()
|
|
335
|
+
self.assertIn("Rule A", prompt_context.emit_rules(self.root, payload))
|
|
336
|
+
(self.rules_dir / "a.md").write_text("Rule A v2", encoding="utf-8")
|
|
337
|
+
self.assertIn(
|
|
338
|
+
"Rule A v2",
|
|
339
|
+
prompt_context.emit_rules(self.root, payload),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def test_emit_off_when_flag_off(self) -> None:
|
|
343
|
+
(self.rules_dir / "a.md").write_text("Rule A", encoding="utf-8")
|
|
344
|
+
self._write_conventions("rules_context: off\n")
|
|
345
|
+
self.assertEqual(
|
|
346
|
+
prompt_context.emit_rules(self.root, self._payload()),
|
|
347
|
+
"",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def test_user_prompt_submit_omits_rules_and_snapshot_on_actionable_prompt(self) -> None:
|
|
351
|
+
(self.rules_dir / "a.md").write_text("Rule A", encoding="utf-8")
|
|
352
|
+
payload = self._payload(cwd=str(self.root), prompt="drain the queue")
|
|
353
|
+
out = io.StringIO()
|
|
354
|
+
with mock.patch.object(
|
|
355
|
+
prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
|
|
356
|
+
), mock.patch.object(prompt_context.sys, "stdout", out):
|
|
357
|
+
rc = prompt_context.main()
|
|
358
|
+
self.assertEqual(rc, 0)
|
|
359
|
+
printed = out.getvalue()
|
|
360
|
+
self.assertIn("Agile Workflow Principles", printed)
|
|
361
|
+
self.assertNotIn("Rule A", printed)
|
|
362
|
+
self.assertNotIn("Agile Workflow Snapshot", printed)
|
|
363
|
+
|
|
364
|
+
def test_short_workflow_command_emits_principles(self) -> None:
|
|
365
|
+
payload = self._payload(cwd=str(self.root), prompt="implement")
|
|
366
|
+
out = io.StringIO()
|
|
367
|
+
with mock.patch.object(
|
|
368
|
+
prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
|
|
369
|
+
), mock.patch.object(prompt_context.sys, "stdout", out):
|
|
370
|
+
rc = prompt_context.main()
|
|
371
|
+
self.assertEqual(rc, 0)
|
|
372
|
+
printed = out.getvalue()
|
|
373
|
+
self.assertIn("Agile Workflow Principles", printed)
|
|
374
|
+
self.assertIn("Code-design capsule", printed)
|
|
375
|
+
|
|
376
|
+
def test_main_sessionstart_emits_rules(self) -> None:
|
|
377
|
+
(self.rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
|
|
378
|
+
payload = {
|
|
379
|
+
"session_id": "s2",
|
|
380
|
+
"hook_event_name": "SessionStart",
|
|
381
|
+
"source": "startup",
|
|
382
|
+
"cwd": str(self.root),
|
|
383
|
+
}
|
|
384
|
+
out = io.StringIO()
|
|
385
|
+
with mock.patch.object(
|
|
386
|
+
prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
|
|
387
|
+
), mock.patch.object(prompt_context.sys, "stdout", out):
|
|
388
|
+
rc = prompt_context.main()
|
|
389
|
+
self.assertEqual(rc, 0)
|
|
390
|
+
printed = out.getvalue()
|
|
391
|
+
self.assertIn("additionalContext", printed)
|
|
392
|
+
self.assertIn("Rule A body", printed)
|
|
393
|
+
|
|
394
|
+
def test_config_prose_does_not_disable(self) -> None:
|
|
395
|
+
self._write_conventions(
|
|
396
|
+
"# Conventions\n- rules_context: off disables injection\n"
|
|
397
|
+
)
|
|
398
|
+
enabled, _ = prompt_context.rules_config(self.root)
|
|
399
|
+
self.assertTrue(enabled, "prose example must not flip the flag")
|
|
400
|
+
|
|
401
|
+
def test_config_invalid_utf8_defaults(self) -> None:
|
|
402
|
+
(self.root / ".work" / "CONVENTIONS.md").write_bytes(
|
|
403
|
+
b"\xff\xfe rules_context: off\n"
|
|
404
|
+
)
|
|
405
|
+
enabled, max_bytes = prompt_context.rules_config(self.root)
|
|
406
|
+
self.assertTrue(enabled)
|
|
407
|
+
self.assertEqual(max_bytes, prompt_context.DEFAULT_RULES_MAX_BYTES)
|
|
408
|
+
|
|
409
|
+
def test_max_bytes_zero_ignored(self) -> None:
|
|
410
|
+
self._write_conventions("rules_context_max_bytes: 0\n")
|
|
411
|
+
_, max_bytes = prompt_context.rules_config(self.root)
|
|
412
|
+
self.assertEqual(max_bytes, prompt_context.DEFAULT_RULES_MAX_BYTES)
|
|
413
|
+
(self.rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
|
|
414
|
+
text, _ = prompt_context.read_rules_dir(self.root, max_bytes)
|
|
415
|
+
self.assertIn("Rule A body", text)
|
|
416
|
+
self.assertNotIn("truncated", text)
|
|
417
|
+
|
|
418
|
+
def test_sessionstart_dedup_suppresses_second_emit(self) -> None:
|
|
419
|
+
(self.rules_dir / "a.md").write_text("Rule A", encoding="utf-8")
|
|
420
|
+
prompt_context.bump_epoch(
|
|
421
|
+
self.root,
|
|
422
|
+
{"session_id": "s1", "hook_event_name": "SessionStart", "source": "startup"},
|
|
423
|
+
)
|
|
424
|
+
first = prompt_context.emit_rules(self.root, {"session_id": "s1"})
|
|
425
|
+
self.assertIn("Rule A", first)
|
|
426
|
+
second = prompt_context.emit_rules(self.root, {"session_id": "s1"})
|
|
427
|
+
self.assertEqual(second, "")
|
|
428
|
+
|
|
429
|
+
def test_main_postcompact_emits(self) -> None:
|
|
430
|
+
(self.rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
|
|
431
|
+
payload = {
|
|
432
|
+
"session_id": "s3",
|
|
433
|
+
"hook_event_name": "PostCompact",
|
|
434
|
+
"source": "auto",
|
|
435
|
+
"cwd": str(self.root),
|
|
436
|
+
}
|
|
437
|
+
out = io.StringIO()
|
|
438
|
+
with mock.patch.object(
|
|
439
|
+
prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
|
|
440
|
+
), mock.patch.object(prompt_context.sys, "stdout", out):
|
|
441
|
+
rc = prompt_context.main()
|
|
442
|
+
self.assertEqual(rc, 0)
|
|
443
|
+
self.assertIn("Rule A body", out.getvalue())
|
|
444
|
+
|
|
445
|
+
def test_main_codex_postcompact_suppresses_unsupported_context_output(self) -> None:
|
|
446
|
+
(self.rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
|
|
447
|
+
payload = {
|
|
448
|
+
"session_id": "codex-s1",
|
|
449
|
+
"hook_event_name": "PostCompact",
|
|
450
|
+
"trigger": "auto",
|
|
451
|
+
"cwd": str(self.root),
|
|
452
|
+
}
|
|
453
|
+
out = io.StringIO()
|
|
454
|
+
with mock.patch.dict(
|
|
455
|
+
prompt_context.os.environ,
|
|
456
|
+
{"PLUGIN_ROOT": "/tmp/agile-workflow-plugin"},
|
|
457
|
+
clear=True,
|
|
458
|
+
), mock.patch.object(
|
|
459
|
+
prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
|
|
460
|
+
), mock.patch.object(prompt_context.sys, "stdout", out):
|
|
461
|
+
rc = prompt_context.main()
|
|
462
|
+
|
|
463
|
+
self.assertEqual(rc, 0)
|
|
464
|
+
self.assertEqual(out.getvalue(), "")
|
|
465
|
+
state = prompt_context.load_state(self.root)
|
|
466
|
+
session = state["sessions"]["codex-s1"]
|
|
467
|
+
self.assertEqual(session["epoch"], 1)
|
|
468
|
+
self.assertNotIn("rules", session["seen"])
|
|
469
|
+
|
|
470
|
+
def test_main_codex_sessionstart_compact_emits_rules(self) -> None:
|
|
471
|
+
(self.rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
|
|
472
|
+
payload = {
|
|
473
|
+
"session_id": "codex-s2",
|
|
474
|
+
"hook_event_name": "SessionStart",
|
|
475
|
+
"source": "compact",
|
|
476
|
+
"cwd": str(self.root),
|
|
477
|
+
}
|
|
478
|
+
out = io.StringIO()
|
|
479
|
+
with mock.patch.dict(
|
|
480
|
+
prompt_context.os.environ,
|
|
481
|
+
{"PLUGIN_ROOT": "/tmp/agile-workflow-plugin"},
|
|
482
|
+
clear=True,
|
|
483
|
+
), mock.patch.object(
|
|
484
|
+
prompt_context.sys, "stdin", io.StringIO(json.dumps(payload))
|
|
485
|
+
), mock.patch.object(prompt_context.sys, "stdout", out):
|
|
486
|
+
rc = prompt_context.main()
|
|
487
|
+
|
|
488
|
+
self.assertEqual(rc, 0)
|
|
489
|
+
printed = out.getvalue()
|
|
490
|
+
self.assertIn("additionalContext", printed)
|
|
491
|
+
self.assertIn("Rule A body", printed)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class StateConcurrencyTest(unittest.TestCase):
|
|
495
|
+
"""Guards the state-file atomicity + concurrency hardening.
|
|
496
|
+
|
|
497
|
+
Covers: (a) save_state uses a per-writer unique temp path, not a fixed
|
|
498
|
+
".json.tmp"; (b) the _state_lock context manager acquires/releases an flock
|
|
499
|
+
and degrades gracefully when fcntl is unavailable; (c) the locked
|
|
500
|
+
read-modify-write does not lose another session's entry across an
|
|
501
|
+
interleaved load/save simulated by hand.
|
|
502
|
+
|
|
503
|
+
State is isolated to a temp file so it does not leak across tests.
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
def setUp(self) -> None:
|
|
507
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
508
|
+
self.root = Path(self._tmp.name)
|
|
509
|
+
self.state_file = self.root / "state" / "hook-state.json"
|
|
510
|
+
self._state_patch = mock.patch.object(
|
|
511
|
+
prompt_context, "state_path", return_value=self.state_file
|
|
512
|
+
)
|
|
513
|
+
self._state_patch.start()
|
|
514
|
+
|
|
515
|
+
def tearDown(self) -> None:
|
|
516
|
+
self._state_patch.stop()
|
|
517
|
+
self._tmp.cleanup()
|
|
518
|
+
|
|
519
|
+
def test_save_state_uses_unique_temp_path(self) -> None:
|
|
520
|
+
"""save_state must NOT write to a fixed ".json.tmp"; two saves must not
|
|
521
|
+
collide on the same temp name."""
|
|
522
|
+
captured: list[str] = []
|
|
523
|
+
real_open = Path.open
|
|
524
|
+
|
|
525
|
+
def spy_open(self_path, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
526
|
+
if str(self_path).endswith(".tmp"):
|
|
527
|
+
captured.append(str(self_path))
|
|
528
|
+
return real_open(self_path, *args, **kwargs)
|
|
529
|
+
|
|
530
|
+
with mock.patch.object(Path, "open", spy_open):
|
|
531
|
+
prompt_context.save_state(self.root, {"version": 1, "sessions": {"a": {}}})
|
|
532
|
+
prompt_context.save_state(self.root, {"version": 1, "sessions": {"b": {}}})
|
|
533
|
+
|
|
534
|
+
self.assertEqual(len(captured), 2, "each save should open exactly one temp")
|
|
535
|
+
# The fixed legacy name would be "<state>.json.tmp"; ensure we moved off it.
|
|
536
|
+
legacy = str(self.state_file.with_suffix(".json.tmp"))
|
|
537
|
+
self.assertNotIn(legacy, captured, "must not use the fixed .json.tmp path")
|
|
538
|
+
# Two saves must use distinct temp names (no clobber).
|
|
539
|
+
self.assertNotEqual(captured[0], captured[1], "temp names must be unique per save")
|
|
540
|
+
# The pid should be embedded so concurrent processes do not collide.
|
|
541
|
+
for name in captured:
|
|
542
|
+
self.assertIn(str(prompt_context.os.getpid()), name)
|
|
543
|
+
# No temp debris remains, and the real file landed.
|
|
544
|
+
leftovers = list(self.state_file.parent.glob("*.tmp"))
|
|
545
|
+
self.assertEqual(leftovers, [], f"temp debris left behind: {leftovers}")
|
|
546
|
+
self.assertTrue(self.state_file.exists())
|
|
547
|
+
|
|
548
|
+
def test_save_state_failure_is_fail_open(self) -> None:
|
|
549
|
+
"""An OSError during the write must not propagate (fail-open)."""
|
|
550
|
+
|
|
551
|
+
def boom(self_path, *args, **kwargs): # type: ignore[no-untyped-def]
|
|
552
|
+
raise OSError("disk full")
|
|
553
|
+
|
|
554
|
+
with mock.patch.object(Path, "open", boom):
|
|
555
|
+
# Must not raise.
|
|
556
|
+
prompt_context.save_state(self.root, {"version": 1, "sessions": {}})
|
|
557
|
+
|
|
558
|
+
def test_state_lock_acquires_and_releases(self) -> None:
|
|
559
|
+
"""When fcntl is present, _state_lock locks then unlocks the lock file."""
|
|
560
|
+
self.assertIsNotNone(
|
|
561
|
+
prompt_context.fcntl, "this platform has fcntl; test the real lock path"
|
|
562
|
+
)
|
|
563
|
+
calls: list[int] = []
|
|
564
|
+
real_flock = prompt_context.fcntl.flock
|
|
565
|
+
|
|
566
|
+
def spy_flock(fd, op): # type: ignore[no-untyped-def]
|
|
567
|
+
calls.append(op)
|
|
568
|
+
return real_flock(fd, op)
|
|
569
|
+
|
|
570
|
+
with mock.patch.object(prompt_context.fcntl, "flock", spy_flock):
|
|
571
|
+
with prompt_context._state_lock(self.root):
|
|
572
|
+
pass
|
|
573
|
+
|
|
574
|
+
self.assertIn(prompt_context.fcntl.LOCK_EX, calls, "should take an exclusive lock")
|
|
575
|
+
self.assertIn(prompt_context.fcntl.LOCK_UN, calls, "should release the lock")
|
|
576
|
+
# The lock file was created next to the state file.
|
|
577
|
+
self.assertTrue(self.state_file.with_suffix(".lock").exists())
|
|
578
|
+
|
|
579
|
+
def test_state_lock_degrades_without_fcntl(self) -> None:
|
|
580
|
+
"""With fcntl monkeypatched out, _state_lock is a graceful no-op."""
|
|
581
|
+
with mock.patch.object(prompt_context, "fcntl", None):
|
|
582
|
+
entered = False
|
|
583
|
+
with prompt_context._state_lock(self.root):
|
|
584
|
+
entered = True
|
|
585
|
+
self.assertTrue(entered, "context body must still run without fcntl")
|
|
586
|
+
# No lock file should be created in the fcntl-less path.
|
|
587
|
+
self.assertFalse(self.state_file.with_suffix(".lock").exists())
|
|
588
|
+
|
|
589
|
+
def test_state_lock_failopen_on_flock_error(self) -> None:
|
|
590
|
+
"""A flock failure must not block/crash — body still runs (unlocked)."""
|
|
591
|
+
|
|
592
|
+
def boom(fd, op): # type: ignore[no-untyped-def]
|
|
593
|
+
raise OSError("flock unavailable")
|
|
594
|
+
|
|
595
|
+
with mock.patch.object(prompt_context.fcntl, "flock", boom):
|
|
596
|
+
entered = False
|
|
597
|
+
with prompt_context._state_lock(self.root):
|
|
598
|
+
entered = True
|
|
599
|
+
self.assertTrue(entered, "must proceed unlocked on flock error")
|
|
600
|
+
|
|
601
|
+
def test_bump_epoch_works_without_fcntl(self) -> None:
|
|
602
|
+
"""The full read-modify-write still works when fcntl is unavailable."""
|
|
603
|
+
with mock.patch.object(prompt_context, "fcntl", None):
|
|
604
|
+
prompt_context.bump_epoch(
|
|
605
|
+
self.root,
|
|
606
|
+
{"session_id": "s1", "hook_event_name": "PostCompact", "source": "auto"},
|
|
607
|
+
)
|
|
608
|
+
state = prompt_context.load_state(self.root)
|
|
609
|
+
self.assertEqual(state["sessions"]["s1"]["epoch"], 1)
|
|
610
|
+
|
|
611
|
+
def test_concurrent_sessions_do_not_lose_entries(self) -> None:
|
|
612
|
+
"""Two sessions writing under the lock must both survive.
|
|
613
|
+
|
|
614
|
+
Simulates the interleave by hand: each call is a full locked
|
|
615
|
+
load -> mutate -> save. Because each acquires the lock and re-loads,
|
|
616
|
+
neither clobbers the other's session entry (the whole-file
|
|
617
|
+
last-writer-wins race the lock removes).
|
|
618
|
+
"""
|
|
619
|
+
prompt_context.bump_epoch(
|
|
620
|
+
self.root,
|
|
621
|
+
{"session_id": "alpha", "hook_event_name": "SessionStart", "source": "startup"},
|
|
622
|
+
)
|
|
623
|
+
prompt_context.bump_epoch(
|
|
624
|
+
self.root,
|
|
625
|
+
{"session_id": "beta", "hook_event_name": "PostCompact", "source": "auto"},
|
|
626
|
+
)
|
|
627
|
+
state = prompt_context.load_state(self.root)
|
|
628
|
+
self.assertIn("alpha", state["sessions"])
|
|
629
|
+
self.assertIn("beta", state["sessions"])
|
|
630
|
+
self.assertEqual(state["sessions"]["beta"]["epoch"], 1)
|
|
631
|
+
|
|
632
|
+
def test_process_interleave_does_not_clobber_postcompact_epoch(self) -> None:
|
|
633
|
+
"""A stale rules writer cannot overwrite a PostCompact bump.
|
|
634
|
+
|
|
635
|
+
This uses two real Python processes so the fcntl lock is exercised as an
|
|
636
|
+
inter-process lock. P1 enters the rules read-modify-write and pauses
|
|
637
|
+
immediately after `load_state` while still holding the lock. P2 attempts
|
|
638
|
+
the PostCompact epoch bump and must wait. When P1 is released, P2 loads
|
|
639
|
+
the post-P1 state and bumps the epoch, so the final state keeps the
|
|
640
|
+
PostCompact epoch instead of being clobbered by P1's stale save.
|
|
641
|
+
"""
|
|
642
|
+
if prompt_context.fcntl is None:
|
|
643
|
+
self.skipTest("requires fcntl for an inter-process lock")
|
|
644
|
+
|
|
645
|
+
conventions = self.root / ".work" / "CONVENTIONS.md"
|
|
646
|
+
conventions.parent.mkdir(parents=True)
|
|
647
|
+
conventions.write_text("# Conventions\n", encoding="utf-8")
|
|
648
|
+
rules_dir = self.root / ".agents" / "rules"
|
|
649
|
+
rules_dir.mkdir(parents=True)
|
|
650
|
+
(rules_dir / "a.md").write_text("Rule A body", encoding="utf-8")
|
|
651
|
+
|
|
652
|
+
loaded_signal = self.root / "loaded.signal"
|
|
653
|
+
release_signal = self.root / "release.signal"
|
|
654
|
+
module_path = str(_MODULE_PATH)
|
|
655
|
+
root_path = str(self.root)
|
|
656
|
+
state_path = str(self.state_file)
|
|
657
|
+
|
|
658
|
+
rules_worker = f"""
|
|
659
|
+
import importlib.util
|
|
660
|
+
import time
|
|
661
|
+
from pathlib import Path
|
|
662
|
+
|
|
663
|
+
module_path = Path({module_path!r})
|
|
664
|
+
root = Path({root_path!r})
|
|
665
|
+
state_file = Path({state_path!r})
|
|
666
|
+
loaded_signal = Path({str(loaded_signal)!r})
|
|
667
|
+
release_signal = Path({str(release_signal)!r})
|
|
668
|
+
|
|
669
|
+
spec = importlib.util.spec_from_file_location("prompt_context_worker", module_path)
|
|
670
|
+
prompt_context = importlib.util.module_from_spec(spec)
|
|
671
|
+
spec.loader.exec_module(prompt_context)
|
|
672
|
+
prompt_context.state_path = lambda _root: state_file
|
|
673
|
+
real_load_state = prompt_context.load_state
|
|
674
|
+
|
|
675
|
+
def paused_load_state(load_root):
|
|
676
|
+
state = real_load_state(load_root)
|
|
677
|
+
loaded_signal.write_text("loaded", encoding="utf-8")
|
|
678
|
+
deadline = time.monotonic() + 5
|
|
679
|
+
while not release_signal.exists():
|
|
680
|
+
if time.monotonic() > deadline:
|
|
681
|
+
raise TimeoutError("release signal was not written")
|
|
682
|
+
time.sleep(0.02)
|
|
683
|
+
return state
|
|
684
|
+
|
|
685
|
+
prompt_context.load_state = paused_load_state
|
|
686
|
+
prompt_context.emit_rules(root, {{"session_id": "race"}})
|
|
687
|
+
"""
|
|
688
|
+
|
|
689
|
+
postcompact_worker = f"""
|
|
690
|
+
import importlib.util
|
|
691
|
+
from pathlib import Path
|
|
692
|
+
|
|
693
|
+
module_path = Path({module_path!r})
|
|
694
|
+
root = Path({root_path!r})
|
|
695
|
+
state_file = Path({state_path!r})
|
|
696
|
+
|
|
697
|
+
spec = importlib.util.spec_from_file_location("prompt_context_worker", module_path)
|
|
698
|
+
prompt_context = importlib.util.module_from_spec(spec)
|
|
699
|
+
spec.loader.exec_module(prompt_context)
|
|
700
|
+
prompt_context.state_path = lambda _root: state_file
|
|
701
|
+
prompt_context.bump_epoch(root, {{"session_id": "race", "hook_event_name": "PostCompact", "source": "auto"}})
|
|
702
|
+
"""
|
|
703
|
+
|
|
704
|
+
p1 = subprocess.Popen(
|
|
705
|
+
[sys.executable, "-c", rules_worker],
|
|
706
|
+
stdout=subprocess.DEVNULL,
|
|
707
|
+
stderr=subprocess.DEVNULL,
|
|
708
|
+
)
|
|
709
|
+
try:
|
|
710
|
+
deadline = time.monotonic() + 5
|
|
711
|
+
while not loaded_signal.exists():
|
|
712
|
+
if p1.poll() is not None:
|
|
713
|
+
self.fail(f"rules worker exited early with {p1.returncode}")
|
|
714
|
+
if time.monotonic() > deadline:
|
|
715
|
+
self.fail("rules worker did not reach the load barrier")
|
|
716
|
+
time.sleep(0.02)
|
|
717
|
+
|
|
718
|
+
p2 = subprocess.Popen(
|
|
719
|
+
[sys.executable, "-c", postcompact_worker],
|
|
720
|
+
stdout=subprocess.DEVNULL,
|
|
721
|
+
stderr=subprocess.DEVNULL,
|
|
722
|
+
)
|
|
723
|
+
try:
|
|
724
|
+
time.sleep(0.15)
|
|
725
|
+
self.assertIsNone(
|
|
726
|
+
p2.poll(),
|
|
727
|
+
"PostCompact worker should be blocked on the state lock until P1 saves",
|
|
728
|
+
)
|
|
729
|
+
release_signal.write_text("release", encoding="utf-8")
|
|
730
|
+
self.assertEqual(p1.wait(timeout=5), 0)
|
|
731
|
+
self.assertEqual(p2.wait(timeout=5), 0)
|
|
732
|
+
finally:
|
|
733
|
+
if p2.poll() is None:
|
|
734
|
+
p2.kill()
|
|
735
|
+
p2.wait()
|
|
736
|
+
finally:
|
|
737
|
+
if p1.poll() is None:
|
|
738
|
+
release_signal.write_text("release", encoding="utf-8")
|
|
739
|
+
p1.kill()
|
|
740
|
+
p1.wait()
|
|
741
|
+
|
|
742
|
+
state = prompt_context.load_state(self.root)
|
|
743
|
+
session = state["sessions"]["race"]
|
|
744
|
+
self.assertEqual(session["epoch"], 1)
|
|
745
|
+
self.assertEqual(session["seen"], {})
|
|
746
|
+
|
|
747
|
+
def test_save_state_roundtrip(self) -> None:
|
|
748
|
+
"""save_state -> load_state preserves data (atomic replace lands)."""
|
|
749
|
+
prompt_context.save_state(
|
|
750
|
+
self.root, {"version": 1, "sessions": {"x": {"epoch": 3, "seen": {}}}}
|
|
751
|
+
)
|
|
752
|
+
loaded = prompt_context.load_state(self.root)
|
|
753
|
+
self.assertEqual(loaded["sessions"]["x"]["epoch"], 3)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
if __name__ == "__main__":
|
|
757
|
+
unittest.main()
|