Skip to content

Commit e8cb860

Browse files
authored
fix: address audit bug regressions (#29)
1 parent b321a07 commit e8cb860

25 files changed

Lines changed: 467 additions & 950 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **StrReplaceFile now refuses ambiguous single replacements.** A non-`replace_all` edit now
19+
errors when `old` matches more than once instead of silently editing the first match. Add
20+
surrounding context to make the old string unique, or pass `replace_all=true` when every
21+
occurrence should change.
22+
1823
## 0.26.0 (2026-05-30)
1924

2025
### What changed in this release

src/pythinker_code/background/manager.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,15 +262,19 @@ def create_bash_task(
262262
self._store.write_runtime(task_id, runtime)
263263
raise
264264

265-
runtime = self._store.read_runtime(task_id)
266-
if runtime.finished_at is None and (
267-
runtime.status == "created"
268-
or (runtime.status == "starting" and runtime.worker_pid is None)
269-
):
265+
def mark_worker_started(runtime: TaskRuntime) -> bool:
266+
if runtime.finished_at is not None:
267+
return False
268+
if runtime.status != "created" and not (
269+
runtime.status == "starting" and runtime.worker_pid is None
270+
):
271+
return False
270272
runtime.status = "starting"
271273
runtime.worker_pid = worker_pid
272274
runtime.updated_at = time.time()
273-
self._store.write_runtime(task_id, runtime)
275+
return True
276+
277+
self._store.update_runtime(task_id, mark_worker_started)
274278
view = self._store.merged_view(task_id)
275279
self._journal_task_milestone("background task started", view)
276280
return view

src/pythinker_code/background/store.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import re
77
import shutil
88
import time
9+
from collections.abc import Callable
910
from contextlib import contextmanager
1011
from pathlib import Path
1112

@@ -139,6 +140,18 @@ def write_runtime(self, task_id: str, runtime: TaskRuntime) -> None:
139140
with self._runtime_lock(task_id):
140141
self._write_runtime_unlocked(task_id, runtime)
141142

143+
def update_runtime(self, task_id: str, update: Callable[[TaskRuntime], bool]) -> TaskRuntime:
144+
"""Apply a read-modify-write update under the runtime lock.
145+
146+
``update`` mutates the current runtime and returns true when it should
147+
be written back. The current runtime is returned either way.
148+
"""
149+
with self._runtime_lock(task_id):
150+
runtime = self.read_runtime(task_id)
151+
if update(runtime):
152+
self._write_runtime_unlocked(task_id, runtime)
153+
return runtime
154+
142155
def _write_runtime_unlocked(self, task_id: str, runtime: TaskRuntime) -> None:
143156
"""Write runtime without acquiring the per-task lock (caller holds it)."""
144157
path = self.runtime_path(task_id)

src/pythinker_code/background/worker.py

Lines changed: 32 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pythinker_code.utils.logging import logger
1313
from pythinker_code.utils.subprocess_env import get_clean_env
1414

15-
from .models import TaskControl
15+
from .models import TaskControl, TaskRuntime
1616
from .store import BackgroundTaskStore
1717

1818

@@ -112,9 +112,9 @@ async def _terminate_process(force: bool = False) -> None:
112112
async def _check_output_limit() -> None:
113113
"""Terminate the task if its output.log grew past ``max_output_bytes``.
114114
115-
Writes a single marker line and records a failure the first time the
116-
limit is hit; the ``output_limit_exceeded`` guard keeps it from
117-
re-marking on subsequent polls or once the process is already exiting.
115+
Writes a single marker line and asks the process to terminate the first
116+
time the limit is hit; the final runtime write records the failure once
117+
the process has exited.
118118
"""
119119
nonlocal output_limit_exceeded, output_limit_reason
120120
if max_output_bytes <= 0 or output_limit_exceeded:
@@ -133,14 +133,6 @@ async def _check_output_limit() -> None:
133133
marker = f"\n... output limit exceeded ({size} bytes); task terminated ...\n"
134134
with contextlib.suppress(OSError), output_path.open("ab") as marker_file:
135135
marker_file.write(marker.encode("utf-8"))
136-
with store._runtime_lock(task_id): # pyright: ignore[reportPrivateUsage]
137-
current = store.read_runtime(task_id)
138-
if not current.finished_at:
139-
current.status = "failed"
140-
current.interrupted = True
141-
current.failure_reason = output_limit_reason
142-
current.updated_at = time.time()
143-
store._write_runtime_unlocked(task_id, current) # pyright: ignore[reportPrivateUsage]
144136
await _terminate_process(force=False)
145137

146138
async def _control_loop() -> None:
@@ -211,7 +203,6 @@ async def _input_loop() -> None:
211203
runtime.updated_at = time.time()
212204
runtime.heartbeat_at = runtime.updated_at
213205
store.write_runtime(task_id, runtime)
214-
last_known_runtime = runtime
215206

216207
heartbeat_task = asyncio.create_task(_heartbeat_loop())
217208
control_task = asyncio.create_task(_control_loop())
@@ -254,29 +245,32 @@ async def _input_loop() -> None:
254245
with contextlib.suppress(asyncio.CancelledError):
255246
await task
256247

257-
runtime = last_known_runtime.model_copy()
258248
control = store.read_control(task_id)
259-
runtime.finished_at = time.time()
260-
runtime.updated_at = runtime.finished_at
261-
runtime.exit_code = returncode
262-
runtime.heartbeat_at = runtime.finished_at
263-
if output_limit_exceeded:
264-
runtime.status = "failed"
265-
runtime.interrupted = True
266-
runtime.failure_reason = output_limit_reason
267-
elif timed_out:
268-
runtime.status = "failed"
269-
runtime.interrupted = True
270-
runtime.timed_out = True
271-
runtime.failure_reason = timeout_reason
272-
elif control.kill_requested_at is not None:
273-
runtime.status = "killed"
274-
runtime.interrupted = True
275-
runtime.failure_reason = control.kill_reason or "Killed"
276-
elif returncode == 0:
277-
runtime.status = "completed"
278-
runtime.failure_reason = None
279-
else:
280-
runtime.status = "failed"
281-
runtime.failure_reason = f"Command failed with exit code {returncode}"
282-
store.write_runtime(task_id, runtime)
249+
250+
def finish_runtime(runtime: TaskRuntime) -> bool:
251+
runtime.finished_at = time.time()
252+
runtime.updated_at = runtime.finished_at
253+
runtime.exit_code = returncode
254+
runtime.heartbeat_at = runtime.finished_at
255+
if output_limit_exceeded:
256+
runtime.status = "failed"
257+
runtime.interrupted = True
258+
runtime.failure_reason = output_limit_reason
259+
elif timed_out:
260+
runtime.status = "failed"
261+
runtime.interrupted = True
262+
runtime.timed_out = True
263+
runtime.failure_reason = timeout_reason
264+
elif control.kill_requested_at is not None:
265+
runtime.status = "killed"
266+
runtime.interrupted = True
267+
runtime.failure_reason = control.kill_reason or "Killed"
268+
elif returncode == 0:
269+
runtime.status = "completed"
270+
runtime.failure_reason = None
271+
else:
272+
runtime.status = "failed"
273+
runtime.failure_reason = f"Command failed with exit code {returncode}"
274+
return True
275+
276+
store.update_runtime(task_id, finish_runtime)

src/pythinker_code/memory/consolidation.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ def _safe_id(value: str) -> str:
2828
return re.sub(r"[^a-z0-9_-]", "-", value.lower())[:32].strip("-") or "candidate"
2929

3030

31+
def _memory_entry_hash(content: str) -> str:
32+
return content_hash(tier="memory", title=content[:60], body=content)
33+
34+
3135
async def inbox_dir(store: ProjectMemoryStore) -> Path:
3236
root = await store._ensure_dir() # pyright: ignore[reportPrivateUsage]
3337
path = root / "memory" / "inbox"
@@ -53,16 +57,16 @@ async def generate_inbox_candidates(
5357
) -> list[InboxCandidate]:
5458
"""Stage scratch/journal candidates for approval-gated durable memory consolidation."""
5559
existing_entries = [*await store.read_entries("memory"), *await store.read_entries("user")]
56-
existing_hashes = {
57-
content_hash(tier="memory", title=entry[:60], body=entry) for entry in existing_entries
58-
}
59-
staged = {candidate.content_hash for candidate in await list_inbox_candidates(store)}
60+
existing_hashes = {_memory_entry_hash(entry) for entry in existing_entries}
61+
inbox_candidates = await list_inbox_candidates(store)
62+
staged = {candidate.content_hash for candidate in inbox_candidates}
63+
staged.update(_memory_entry_hash(candidate.content) for candidate in inbox_candidates)
6064
directory = await inbox_dir(store)
6165
candidates: list[InboxCandidate] = []
6266
for block in await gather_candidates(store, work_dir):
6367
if block.tier in {"memory", "user"}:
6468
continue
65-
digest = content_hash(tier="memory", title=block.title, body=block.content)
69+
digest = _memory_entry_hash(block.content)
6670
if digest in existing_hashes or digest in staged:
6771
continue
6872
candidate = InboxCandidate(
@@ -74,7 +78,14 @@ async def generate_inbox_candidates(
7478
content_hash=digest,
7579
)
7680
path = directory / f"{candidate.id}.json"
77-
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
81+
try:
82+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
83+
except FileExistsError:
84+
# A file with this id already exists on disk but was absent from
85+
# ``staged`` (e.g. it is corrupt and was skipped during listing).
86+
# Treat it as already staged instead of crashing the whole harvest.
87+
staged.add(digest)
88+
continue
7889
with os.fdopen(fd, "w", encoding="utf-8") as fh:
7990
json.dump(asdict(candidate), fh, ensure_ascii=False, indent=2)
8091
candidates.append(candidate)

src/pythinker_code/memory/retriever.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
import math
44
import re
55
import time
6+
import unicodedata
67
from abc import ABC, abstractmethod
78
from dataclasses import dataclass, replace
89

9-
_TOKEN_RE = re.compile(r"[a-z0-9]{2,}")
10+
# Unicode word runs, with underscores treated as separators for snake_case.
11+
_TOKEN_RE = re.compile(r"[^\W_]{2,}", re.UNICODE)
1012
_RECENCY_HALF_LIFE_DAYS = 14.0
1113
_BM25_K1 = 1.5
1214
_BM25_B = 0.75
13-
_LABEL_BOOST = 0.5
1415
_PATH_BOOST = 0.5
1516

1617

@@ -19,7 +20,8 @@ def estimate_tokens(text: str) -> int:
1920

2021

2122
def _tokenize(text: str) -> list[str]:
22-
return _TOKEN_RE.findall(text.lower())
23+
normalized = unicodedata.normalize("NFKC", text).casefold()
24+
return _TOKEN_RE.findall(normalized)
2325

2426

2527
@dataclass(frozen=True, slots=True)
@@ -67,7 +69,7 @@ async def retrieve(self, query: RecallQuery, budget_tokens: int) -> list[RankedB
6769
for term in set(doc):
6870
df[term] = df.get(term, 0) + 1
6971

70-
q_terms = _tokenize(query.text)
72+
q_terms = _tokenize(" ".join((query.text, *query.labels)))
7173
scored: list[RankedBlock] = []
7274
for cand, doc in zip(self._candidates, docs, strict=True):
7375
dl = len(doc) or 1
@@ -91,8 +93,6 @@ async def retrieve(self, query: RecallQuery, budget_tokens: int) -> list[RankedB
9193
boost = 0.0
9294
if any(path in cand.files for path in query.paths):
9395
boost += _PATH_BOOST
94-
if set(query.labels) & set(cand.labels):
95-
boost += _LABEL_BOOST
9696
if not q_terms and not query.paths and not query.labels:
9797
boost += 0.01
9898
scored.append(replace(cand, score=bm25 * decay + boost))

0 commit comments

Comments
 (0)