patchground 0.1.0__py3-none-any.whl
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.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Command-line entry for the reusable validation Action.
|
|
2
|
+
|
|
3
|
+
Run as ``python -I -m patchground.automation``. Only fixed, path-free messages are emitted.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import contextlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, NoReturn
|
|
17
|
+
|
|
18
|
+
from patchground.safeio import SafetyError, open_directory
|
|
19
|
+
|
|
20
|
+
ERROR_FORMAT = "patchground-automation-error"
|
|
21
|
+
USAGE_EXIT = 2
|
|
22
|
+
CANCEL_EXIT = 130
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _UsageError(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Parser(argparse.ArgumentParser):
|
|
30
|
+
def error(self, message: str) -> NoReturn:
|
|
31
|
+
raise _UsageError("invalid command-line arguments")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = _Parser(prog="python -m patchground.automation", add_help=False, exit_on_error=False)
|
|
36
|
+
parser.add_argument("--source", required=True)
|
|
37
|
+
parser.add_argument("--directory", default=".")
|
|
38
|
+
parser.add_argument("--base", default=None)
|
|
39
|
+
parser.add_argument("--consent", action="store_true")
|
|
40
|
+
parser.add_argument("--report", required=True)
|
|
41
|
+
return parser
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _error_document(message: str) -> dict[str, Any]:
|
|
45
|
+
return {"format": ERROR_FORMAT, "version": 1, "exit_code": USAGE_EXIT, "error": message}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _reserve_report(report: str, source: Path) -> int:
|
|
49
|
+
"""Exclusively create the report in a pinned, link-free parent outside the checkout."""
|
|
50
|
+
path = Path(report).absolute()
|
|
51
|
+
if ".." in path.parts or path.name in {"", "."}:
|
|
52
|
+
raise _UsageError("report path must name a file without '..' components")
|
|
53
|
+
parent = path.parent
|
|
54
|
+
if parent == source or source in parent.parents:
|
|
55
|
+
raise _UsageError("report path must be outside the source checkout")
|
|
56
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
|
|
57
|
+
try:
|
|
58
|
+
with open_directory(parent, label="report") as pinned:
|
|
59
|
+
return os.open(path.name, flags, 0o600, dir_fd=pinned)
|
|
60
|
+
except FileExistsError:
|
|
61
|
+
raise _UsageError("report file already exists") from None
|
|
62
|
+
except (SafetyError, OSError):
|
|
63
|
+
raise _UsageError("report location is unsafe or unavailable") from None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _write(fd: int, document: dict[str, Any]) -> None:
|
|
67
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
68
|
+
json.dump(document, handle, indent=2, sort_keys=True)
|
|
69
|
+
handle.write("\n")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _default_validate() -> Callable[..., Any]:
|
|
73
|
+
from patchground.automation import validate_changes
|
|
74
|
+
|
|
75
|
+
return validate_changes
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(
|
|
79
|
+
argv: Sequence[str] | None = None,
|
|
80
|
+
*,
|
|
81
|
+
validate: Callable[..., Any] | None = None,
|
|
82
|
+
) -> int:
|
|
83
|
+
fd: int | None = None
|
|
84
|
+
document: dict[str, Any] = _error_document("automation failed unexpectedly")
|
|
85
|
+
code = USAGE_EXIT
|
|
86
|
+
try:
|
|
87
|
+
try:
|
|
88
|
+
args = _parser().parse_args(argv)
|
|
89
|
+
except argparse.ArgumentError:
|
|
90
|
+
raise _UsageError("invalid command-line arguments") from None
|
|
91
|
+
source = Path(args.source).resolve()
|
|
92
|
+
if not source.is_dir():
|
|
93
|
+
raise _UsageError("source is not a directory")
|
|
94
|
+
fd = _reserve_report(args.report, source)
|
|
95
|
+
run = validate or _default_validate()
|
|
96
|
+
result = run(
|
|
97
|
+
source,
|
|
98
|
+
directory=args.directory,
|
|
99
|
+
base=args.base or None,
|
|
100
|
+
consent=args.consent,
|
|
101
|
+
)
|
|
102
|
+
document, code = result.to_dict(), int(result.exit_code)
|
|
103
|
+
reserved, fd = fd, None
|
|
104
|
+
_write(reserved, document)
|
|
105
|
+
print(f"patchground validation finished with exit code {code}; report written")
|
|
106
|
+
return code
|
|
107
|
+
except _UsageError as exc:
|
|
108
|
+
document, code = _error_document(str(exc)), USAGE_EXIT
|
|
109
|
+
except KeyboardInterrupt:
|
|
110
|
+
document, code = _error_document("cancelled"), CANCEL_EXIT
|
|
111
|
+
document["exit_code"] = code
|
|
112
|
+
except Exception as exc: # report type only: messages may carry paths or source text
|
|
113
|
+
document = _error_document(f"automation failed unexpectedly ({type(exc).__name__})")
|
|
114
|
+
code = USAGE_EXIT
|
|
115
|
+
finally:
|
|
116
|
+
# Leaves no open descriptor behind; a repeated interrupt here is safe to ignore.
|
|
117
|
+
pending, fd = fd, None
|
|
118
|
+
if pending is not None:
|
|
119
|
+
with contextlib.suppress(OSError, KeyboardInterrupt):
|
|
120
|
+
_write(pending, document)
|
|
121
|
+
print(json.dumps(document, sort_keys=True), file=sys.stderr)
|
|
122
|
+
return code
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _terminate(signum: int, frame: Any) -> NoReturn:
|
|
126
|
+
raise KeyboardInterrupt
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
signal.signal(signal.SIGTERM, _terminate)
|
|
131
|
+
sys.exit(main())
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Select committed challenges and aggregate the existing verifier's safe facts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
from tempfile import TemporaryDirectory
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from patchground import verify
|
|
13
|
+
from patchground.packs.discovery import discover_challenges
|
|
14
|
+
from patchground.packs.models import PackError, SourceSnapshot
|
|
15
|
+
from patchground.packs.source import fetch_pack
|
|
16
|
+
from patchground.packs.store import capture_tree
|
|
17
|
+
from patchground.spec import ManifestError, load_challenge, load_pack
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AutomationResult:
|
|
22
|
+
"""One JSON-safe outcome, containing no verifier diagnostics or scratch paths."""
|
|
23
|
+
|
|
24
|
+
exit_code: int = 0
|
|
25
|
+
commit: str | None = None
|
|
26
|
+
base_commit: str | None = None
|
|
27
|
+
selection_mode: str = "all"
|
|
28
|
+
selected: list[str] = field(default_factory=list)
|
|
29
|
+
unchanged: list[str] = field(default_factory=list)
|
|
30
|
+
removed: list[str] = field(default_factory=list)
|
|
31
|
+
validations: list[dict[str, Any]] = field(default_factory=list)
|
|
32
|
+
errors: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, Any]:
|
|
35
|
+
return {
|
|
36
|
+
"format": "patchground-automation",
|
|
37
|
+
"format_version": 1,
|
|
38
|
+
"commit": self.commit,
|
|
39
|
+
"base_commit": self.base_commit,
|
|
40
|
+
"selection_mode": self.selection_mode,
|
|
41
|
+
"status": "failed" if self.exit_code else "validated" if self.selected else "skipped",
|
|
42
|
+
"exit_code": self.exit_code,
|
|
43
|
+
"selected": list(self.selected),
|
|
44
|
+
"unchanged": list(self.unchanged),
|
|
45
|
+
"removed": list(self.removed),
|
|
46
|
+
"validations": list(self.validations),
|
|
47
|
+
"errors": list(self.errors),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextmanager
|
|
52
|
+
def _snapshot(
|
|
53
|
+
source: Path, *, ref: str = "HEAD"
|
|
54
|
+
) -> Iterator[tuple[SourceSnapshot, tuple[Path, ...]]]:
|
|
55
|
+
with fetch_pack(source, ref=ref) as snapshot:
|
|
56
|
+
if not (snapshot.path / "challenge.yaml").is_file():
|
|
57
|
+
yield snapshot, (snapshot.path,)
|
|
58
|
+
return
|
|
59
|
+
# The verifier requires the root folder name to match the manifest id. Git transport's
|
|
60
|
+
# scratch name is not the authored checkout name; preserve that identity in our own copy.
|
|
61
|
+
with TemporaryDirectory(prefix="patchground-automation-") as temporary:
|
|
62
|
+
target = Path(temporary).resolve() / Path(snapshot.source).name
|
|
63
|
+
target.mkdir()
|
|
64
|
+
capture_tree(snapshot.path.resolve(), target, seal=False)
|
|
65
|
+
yield snapshot.model_copy(update={"path": target}), (snapshot.path, target)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _candidates(root: Path, directory: str) -> list[str]:
|
|
69
|
+
folder = root / directory
|
|
70
|
+
if not folder.is_dir():
|
|
71
|
+
raise ValueError("selected directory is unavailable")
|
|
72
|
+
if (folder / "pack.yaml").exists():
|
|
73
|
+
pack = load_pack(folder)
|
|
74
|
+
paths = [folder / name for name in pack.manifest.challenges]
|
|
75
|
+
elif (folder / "challenge.yaml").exists():
|
|
76
|
+
paths = [folder]
|
|
77
|
+
else:
|
|
78
|
+
discovered = discover_challenges(folder)
|
|
79
|
+
paths = [Path(item.path) for item in discovered.challenges]
|
|
80
|
+
paths.extend(Path(item.path) for item in discovered.invalid)
|
|
81
|
+
# Preflight every candidate, including unchanged ones, before any build.
|
|
82
|
+
for path in paths:
|
|
83
|
+
load_challenge(path)
|
|
84
|
+
return sorted(path.relative_to(root).as_posix() for path in paths)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _pack_metadata(folder: Path) -> bytes | None:
|
|
88
|
+
manifest = folder / "pack.yaml"
|
|
89
|
+
return manifest.read_bytes() if manifest.is_file() else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _manifest_errors(exc: ManifestError, root: Path) -> list[str]:
|
|
93
|
+
return [
|
|
94
|
+
f"{issue.file.relative_to(root).as_posix()}:{issue.line or 1}: "
|
|
95
|
+
"invalid manifest or challenge layout"
|
|
96
|
+
for issue in exc.issues
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_changes(
|
|
101
|
+
source: Path,
|
|
102
|
+
*,
|
|
103
|
+
directory: str = ".",
|
|
104
|
+
base: str | None = None,
|
|
105
|
+
consent: bool = False,
|
|
106
|
+
sandbox: verify.Sandbox | None = None,
|
|
107
|
+
) -> AutomationResult:
|
|
108
|
+
"""Validate selected raw committed inputs with explicit build consent and no writes."""
|
|
109
|
+
result = AutomationResult()
|
|
110
|
+
relative = PurePosixPath(directory)
|
|
111
|
+
if (
|
|
112
|
+
not directory
|
|
113
|
+
or relative.is_absolute()
|
|
114
|
+
or ".." in relative.parts
|
|
115
|
+
or "\\" in directory
|
|
116
|
+
or ":" in directory
|
|
117
|
+
or any(ord(char) < 32 for char in directory)
|
|
118
|
+
):
|
|
119
|
+
result.exit_code = 2
|
|
120
|
+
result.errors.append("directory must be a relative path within the committed source")
|
|
121
|
+
return result
|
|
122
|
+
try:
|
|
123
|
+
with _snapshot(Path(source)) as (current, captured_roots):
|
|
124
|
+
result.commit = current.commit
|
|
125
|
+
try:
|
|
126
|
+
candidates = _candidates(current.path, directory)
|
|
127
|
+
except ManifestError as exc:
|
|
128
|
+
result.exit_code = 1
|
|
129
|
+
result.errors = _manifest_errors(exc, current.path)
|
|
130
|
+
return result
|
|
131
|
+
except ValueError:
|
|
132
|
+
result.exit_code = 1
|
|
133
|
+
result.errors.append("selected directory has invalid challenge candidates")
|
|
134
|
+
return result
|
|
135
|
+
if not candidates:
|
|
136
|
+
result.exit_code = 1
|
|
137
|
+
result.errors.append("selected directory has no discoverable challenges")
|
|
138
|
+
return result
|
|
139
|
+
result.selected = candidates
|
|
140
|
+
if base is not None:
|
|
141
|
+
result.selection_mode = "fallback_all"
|
|
142
|
+
try:
|
|
143
|
+
with _snapshot(Path(source), ref=base) as (previous, _):
|
|
144
|
+
old = _candidates(previous.path, directory)
|
|
145
|
+
metadata_changed = _pack_metadata(
|
|
146
|
+
current.path / directory
|
|
147
|
+
) != _pack_metadata(previous.path / directory)
|
|
148
|
+
result.removed = sorted(set(old) - set(candidates))
|
|
149
|
+
result.unchanged = [
|
|
150
|
+
name
|
|
151
|
+
for name in candidates
|
|
152
|
+
if name in old
|
|
153
|
+
and not metadata_changed
|
|
154
|
+
and capture_tree(
|
|
155
|
+
(current.path / name).resolve(), None, seal=False
|
|
156
|
+
).digest
|
|
157
|
+
== capture_tree(
|
|
158
|
+
(previous.path / name).resolve(), None, seal=False
|
|
159
|
+
).digest
|
|
160
|
+
]
|
|
161
|
+
result.selected = [
|
|
162
|
+
name for name in candidates if name not in result.unchanged
|
|
163
|
+
]
|
|
164
|
+
result.base_commit = previous.commit
|
|
165
|
+
result.selection_mode = "changed"
|
|
166
|
+
except (PackError, ManifestError, ValueError, OSError):
|
|
167
|
+
# An unusable comparison must never turn into a silent skip.
|
|
168
|
+
result.selected = candidates
|
|
169
|
+
result.unchanged = []
|
|
170
|
+
result.removed = []
|
|
171
|
+
if result.selected and not consent:
|
|
172
|
+
result.exit_code = 2
|
|
173
|
+
result.errors.append("explicit execution consent is required before building")
|
|
174
|
+
return result
|
|
175
|
+
for name in result.selected:
|
|
176
|
+
try:
|
|
177
|
+
report = verify.validate_challenge(
|
|
178
|
+
current.path / name,
|
|
179
|
+
write=False,
|
|
180
|
+
sandbox=sandbox,
|
|
181
|
+
input_roots=(Path(current.source), *captured_roots),
|
|
182
|
+
)
|
|
183
|
+
except verify.VerifyEnvironmentError:
|
|
184
|
+
result.exit_code = 2
|
|
185
|
+
result.errors.append(f"{name}: validation infrastructure unavailable")
|
|
186
|
+
continue
|
|
187
|
+
result.validations.append(
|
|
188
|
+
{
|
|
189
|
+
"path": name,
|
|
190
|
+
"valid": report.valid,
|
|
191
|
+
"failed_step": report.failed_step.value if report.failed_step else None,
|
|
192
|
+
"steps": [
|
|
193
|
+
{
|
|
194
|
+
"name": step.name.value,
|
|
195
|
+
"status": step.status.value,
|
|
196
|
+
"runs": [
|
|
197
|
+
{
|
|
198
|
+
"label": run.label,
|
|
199
|
+
"outcome": run.outcome.value,
|
|
200
|
+
"counts": run.to_dict()["counts"],
|
|
201
|
+
}
|
|
202
|
+
for run in step.runs
|
|
203
|
+
],
|
|
204
|
+
}
|
|
205
|
+
for step in report.steps
|
|
206
|
+
],
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
if not report.valid and result.exit_code == 0:
|
|
210
|
+
result.exit_code = 1
|
|
211
|
+
except (PackError, OSError):
|
|
212
|
+
result.exit_code = 2
|
|
213
|
+
result.errors.append("committed source could not be read safely")
|
|
214
|
+
return result
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
Each folder here is a complete, hand-made challenge in the v0.1 format
|
|
4
|
+
(`docs/contracts/challenge-format.md`): a manifest, a ticket, a starting workspace, an image
|
|
5
|
+
definition, visible and hidden checks, and a reference patch. They are small on purpose — they show
|
|
6
|
+
authors what a good challenge looks like and give Patchground a known-good input for its own tests.
|
|
7
|
+
|
|
8
|
+
Every example must pass `pgnd validate examples/<id>`: the manifest is valid, the image builds, the
|
|
9
|
+
starting workspace fails at least one hidden check, and the reference patch makes every check pass.
|
|
10
|
+
A change that breaks validation of an example is a regression.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Profiles stay stale, and bad TTLs are accepted silently
|
|
2
|
+
|
|
3
|
+
**Reported by:** support and on-call (two tickets merged)
|
|
4
|
+
**Component:** `profilecache`
|
|
5
|
+
|
|
6
|
+
## Symptom
|
|
7
|
+
|
|
8
|
+
1. Users who keep a dashboard open see an old display name for as long as the dashboard keeps
|
|
9
|
+
polling. Profiles that nobody is looking at refresh fine. A profile cached with a 30 second
|
|
10
|
+
lifetime but polled every 10 seconds was still served unchanged after several minutes.
|
|
11
|
+
2. In tests that drive the clock by hand, a profile cached for 30 seconds is still served at exactly
|
|
12
|
+
30 seconds and only disappears a moment later. Operations tooling that expires entries on a
|
|
13
|
+
schedule shows the same one-tick delay.
|
|
14
|
+
3. A deploy accidentally configured a cache lifetime of `0`. Nothing complained, and a negative value
|
|
15
|
+
from a config typo was accepted the same way.
|
|
16
|
+
|
|
17
|
+
## Reproduction
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from profilecache import TTLCache
|
|
21
|
+
|
|
22
|
+
now = [0.0]
|
|
23
|
+
cache = TTLCache(default_ttl=10, clock=lambda: now[0])
|
|
24
|
+
|
|
25
|
+
cache.set("a", 1)
|
|
26
|
+
now[0] = 6
|
|
27
|
+
cache.get("a") # a read at 6s
|
|
28
|
+
now[0] = 10
|
|
29
|
+
print(cache.get("a")) # expected: None, actual: 1
|
|
30
|
+
|
|
31
|
+
cache.set("b", 2, ttl=0) # accepted without error
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Expected
|
|
35
|
+
|
|
36
|
+
- An entry lives for exactly its TTL: at the instant the clock reaches its deadline it is expired.
|
|
37
|
+
This applies to `get`, `in` and `purge_expired` alike.
|
|
38
|
+
- Writing a key starts its lifetime again with the TTL given for that write. Reading a key never
|
|
39
|
+
changes how long it lives.
|
|
40
|
+
- A TTL of zero or less is invalid, both as the cache default and for an individual write. It must
|
|
41
|
+
raise `ValueError`, and a rejected write must leave the cache exactly as it was.
|
|
42
|
+
|
|
43
|
+
## Notes
|
|
44
|
+
|
|
45
|
+
- Keep the injectable clock; do not sleep or use real time in tests.
|
|
46
|
+
- Run `pgnd check` from the session workspace to check your changes in Docker.
|
|
47
|
+
- Add regression tests for the reported behavior and keep existing behavior intact.
|
|
48
|
+
- You may use your own editor and AI tools. The goal is to explain the cause and verify your fix,
|
|
49
|
+
not merely make the reported cases pass.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
spec_version: "0.1"
|
|
2
|
+
id: cache-expiry
|
|
3
|
+
version: 1.0.1
|
|
4
|
+
title: Cached profiles never expire while they are being read
|
|
5
|
+
type: debug
|
|
6
|
+
summary: >-
|
|
7
|
+
A TTL cache keeps entries alive when they are read, expires them one tick late and accepts
|
|
8
|
+
nonpositive TTLs. Find the causes and fix them without changing write behavior.
|
|
9
|
+
languages: [python]
|
|
10
|
+
tags: [caching, ttl, time]
|
|
11
|
+
difficulty: intermediate
|
|
12
|
+
timebox_minutes: 30
|
|
13
|
+
ai_policy: allowed
|
|
14
|
+
environment:
|
|
15
|
+
dockerfile: environment/Dockerfile
|
|
16
|
+
services: []
|
|
17
|
+
test_command: pytest -q -p no:cacheprovider --junitxml=$PG_RESULTS/junit.xml
|
|
18
|
+
checks:
|
|
19
|
+
visible: checks/visible
|
|
20
|
+
hidden: checks/hidden
|
|
21
|
+
solution: solution/reference.patch
|
|
22
|
+
expected_paths: [src/profilecache/, tests/]
|
|
23
|
+
hints:
|
|
24
|
+
- Write down, for one entry, the clock value at which it should expire, then step through what each method does to that deadline.
|
|
25
|
+
- Is the deadline moved by anything other than a write?
|
|
26
|
+
debrief:
|
|
27
|
+
questions: debrief/questions.md
|
|
28
|
+
focus: [debugging_method, test_quality, scope_judgment]
|
|
29
|
+
author: {name: Patchground contributors}
|
|
30
|
+
content_license: CC-BY-4.0
|
|
31
|
+
provenance: generated
|
|
32
|
+
generated_by: {model: "Claude Code configured default (model unverified)", prompt_version: launch-cache-v1}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from profilecache import ProfileClient, TTLCache
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Clock:
|
|
7
|
+
def __init__(self, now: float = 500.0) -> None:
|
|
8
|
+
self.now = now
|
|
9
|
+
|
|
10
|
+
def __call__(self) -> float:
|
|
11
|
+
return self.now
|
|
12
|
+
|
|
13
|
+
def advance(self, seconds: float) -> None:
|
|
14
|
+
self.now += seconds
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_entry_alive_just_before_deadline():
|
|
18
|
+
clock = Clock()
|
|
19
|
+
cache = TTLCache(default_ttl=10, clock=clock)
|
|
20
|
+
cache.set("a", 1)
|
|
21
|
+
clock.advance(9.5)
|
|
22
|
+
assert cache.get("a") == 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_entry_expires_exactly_at_deadline():
|
|
26
|
+
clock = Clock()
|
|
27
|
+
cache = TTLCache(default_ttl=10, clock=clock)
|
|
28
|
+
cache.set("a", 1)
|
|
29
|
+
clock.advance(10)
|
|
30
|
+
assert cache.get("a", "gone") == "gone"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_contains_is_false_exactly_at_deadline():
|
|
34
|
+
clock = Clock()
|
|
35
|
+
cache = TTLCache(default_ttl=10, clock=clock)
|
|
36
|
+
cache.set("a", 1)
|
|
37
|
+
clock.advance(10)
|
|
38
|
+
assert "a" not in cache
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_purge_removes_entry_exactly_at_deadline():
|
|
42
|
+
clock = Clock()
|
|
43
|
+
cache = TTLCache(default_ttl=10, clock=clock)
|
|
44
|
+
cache.set("a", 1)
|
|
45
|
+
cache.set("b", 2, ttl=20)
|
|
46
|
+
clock.advance(10)
|
|
47
|
+
assert cache.purge_expired() == 1
|
|
48
|
+
assert cache.get("b") == 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_read_does_not_extend_lifetime():
|
|
52
|
+
clock = Clock()
|
|
53
|
+
cache = TTLCache(default_ttl=10, clock=clock)
|
|
54
|
+
cache.set("a", 1)
|
|
55
|
+
clock.advance(6)
|
|
56
|
+
assert cache.get("a") == 1
|
|
57
|
+
clock.advance(4)
|
|
58
|
+
assert cache.get("a", "gone") == "gone"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_repeated_reads_never_keep_entry_alive():
|
|
62
|
+
clock = Clock()
|
|
63
|
+
cache = TTLCache(default_ttl=10, clock=clock)
|
|
64
|
+
cache.set("a", 1)
|
|
65
|
+
for _ in range(4):
|
|
66
|
+
clock.advance(2.5)
|
|
67
|
+
cache.get("a")
|
|
68
|
+
assert "a" not in cache
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_write_refreshes_lifetime():
|
|
72
|
+
clock = Clock()
|
|
73
|
+
cache = TTLCache(default_ttl=10, clock=clock)
|
|
74
|
+
cache.set("a", 1)
|
|
75
|
+
clock.advance(8)
|
|
76
|
+
cache.set("a", 2)
|
|
77
|
+
clock.advance(8)
|
|
78
|
+
assert cache.get("a") == 2
|
|
79
|
+
clock.advance(2)
|
|
80
|
+
assert cache.get("a", "gone") == "gone"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_write_can_shorten_lifetime():
|
|
84
|
+
clock = Clock()
|
|
85
|
+
cache = TTLCache(default_ttl=100, clock=clock)
|
|
86
|
+
cache.set("a", 1)
|
|
87
|
+
cache.set("a", 2, ttl=5)
|
|
88
|
+
clock.advance(5)
|
|
89
|
+
assert cache.get("a", "gone") == "gone"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_zero_ttl_write_is_rejected():
|
|
93
|
+
cache = TTLCache(clock=Clock())
|
|
94
|
+
with pytest.raises(ValueError):
|
|
95
|
+
cache.set("a", 1, ttl=0)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_negative_ttl_write_is_rejected():
|
|
99
|
+
cache = TTLCache(clock=Clock())
|
|
100
|
+
with pytest.raises(ValueError):
|
|
101
|
+
cache.set("a", 1, ttl=-3)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_nonpositive_default_ttl_is_rejected():
|
|
105
|
+
with pytest.raises(ValueError):
|
|
106
|
+
TTLCache(default_ttl=0, clock=Clock())
|
|
107
|
+
with pytest.raises(ValueError):
|
|
108
|
+
TTLCache(default_ttl=-1, clock=Clock())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_rejected_write_keeps_existing_entry():
|
|
112
|
+
clock = Clock()
|
|
113
|
+
cache = TTLCache(default_ttl=10, clock=clock)
|
|
114
|
+
cache.set("a", 1)
|
|
115
|
+
with pytest.raises(ValueError):
|
|
116
|
+
cache.set("a", 2, ttl=0)
|
|
117
|
+
assert cache.get("a") == 1
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_rejected_write_does_not_store_new_key():
|
|
121
|
+
cache = TTLCache(clock=Clock())
|
|
122
|
+
with pytest.raises(ValueError):
|
|
123
|
+
cache.set("a", 1, ttl=-1)
|
|
124
|
+
assert "a" not in cache
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_client_refetches_at_ttl_boundary():
|
|
128
|
+
clock = Clock()
|
|
129
|
+
calls = []
|
|
130
|
+
|
|
131
|
+
def fetch(user_id):
|
|
132
|
+
calls.append(user_id)
|
|
133
|
+
return {"id": user_id, "n": len(calls)}
|
|
134
|
+
|
|
135
|
+
client = ProfileClient(fetch, cache=TTLCache(clock=clock), ttl=30)
|
|
136
|
+
client.get("u1")
|
|
137
|
+
clock.advance(30)
|
|
138
|
+
assert client.get("u1")["n"] == 2
|
|
139
|
+
assert calls == ["u1", "u1"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_client_sees_fresh_profile_despite_frequent_reads():
|
|
143
|
+
clock = Clock()
|
|
144
|
+
calls = []
|
|
145
|
+
|
|
146
|
+
def fetch(user_id):
|
|
147
|
+
calls.append(user_id)
|
|
148
|
+
return {"id": user_id, "n": len(calls)}
|
|
149
|
+
|
|
150
|
+
client = ProfileClient(fetch, cache=TTLCache(clock=clock), ttl=30)
|
|
151
|
+
for _ in range(7):
|
|
152
|
+
client.get("u1")
|
|
153
|
+
clock.advance(10)
|
|
154
|
+
assert len(calls) == 3
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_client_update_restarts_lifetime():
|
|
158
|
+
clock = Clock()
|
|
159
|
+
client = ProfileClient(lambda uid: {"id": uid, "v": 1}, cache=TTLCache(clock=clock), ttl=30)
|
|
160
|
+
client.get("u1")
|
|
161
|
+
clock.advance(25)
|
|
162
|
+
client.update("u1", {"id": "u1", "v": 2})
|
|
163
|
+
clock.advance(25)
|
|
164
|
+
assert client.get("u1")["v"] == 2
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from profilecache import ProfileClient, TTLCache
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FakeClock:
|
|
5
|
+
def __init__(self, now: float = 1000.0) -> None:
|
|
6
|
+
self.now = now
|
|
7
|
+
|
|
8
|
+
def __call__(self) -> float:
|
|
9
|
+
return self.now
|
|
10
|
+
|
|
11
|
+
def advance(self, seconds: float) -> None:
|
|
12
|
+
self.now += seconds
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_get_returns_stored_value():
|
|
16
|
+
cache = TTLCache(default_ttl=10, clock=FakeClock())
|
|
17
|
+
cache.set("a", 1)
|
|
18
|
+
assert cache.get("a") == 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_missing_key_returns_default():
|
|
22
|
+
cache = TTLCache(default_ttl=10, clock=FakeClock())
|
|
23
|
+
assert cache.get("nope") is None
|
|
24
|
+
assert cache.get("nope", "fallback") == "fallback"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_entry_is_gone_well_after_deadline():
|
|
28
|
+
clock = FakeClock()
|
|
29
|
+
cache = TTLCache(default_ttl=10, clock=clock)
|
|
30
|
+
cache.set("a", 1)
|
|
31
|
+
clock.advance(60)
|
|
32
|
+
assert cache.get("a") is None
|
|
33
|
+
assert "a" not in cache
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_per_write_ttl_overrides_default():
|
|
37
|
+
clock = FakeClock()
|
|
38
|
+
cache = TTLCache(default_ttl=100, clock=clock)
|
|
39
|
+
cache.set("short", 1, ttl=5)
|
|
40
|
+
clock.advance(20)
|
|
41
|
+
assert cache.get("short") is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_client_fetches_once_while_fresh():
|
|
45
|
+
clock = FakeClock()
|
|
46
|
+
calls = []
|
|
47
|
+
|
|
48
|
+
def fetch(user_id):
|
|
49
|
+
calls.append(user_id)
|
|
50
|
+
return {"id": user_id}
|
|
51
|
+
|
|
52
|
+
client = ProfileClient(fetch, cache=TTLCache(clock=clock), ttl=30)
|
|
53
|
+
assert client.get("u1") == {"id": "u1"}
|
|
54
|
+
clock.advance(5)
|
|
55
|
+
assert client.get("u1") == {"id": "u1"}
|
|
56
|
+
assert calls == ["u1"]
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
- Which of the three symptoms shared a root cause, and which did not? How did you decide?
|
|
2
|
+
- How did you check the expiry boundary without sleeping, and why does an injected clock matter here?
|
|
3
|
+
- Which behavior did you want to leave unchanged while fixing reads, and how did you protect it?
|
|
4
|
+
- If an AI assistant proposed a fix, what did you verify yourself before accepting it?
|