Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion loopx/semantics/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def _assigned_name(node: ast.AST) -> tuple[str, ast.AST] | None:
return None


def parse_python(source: SourceFile) -> ast.AST:
def parse_python(source: SourceFile) -> ast.Module:
"""Parse one tracked Python source, rejecting an unparseable one by path.

Deliberately not cached. Caching the trees would hold roughly two million
Expand Down
70 changes: 43 additions & 27 deletions loopx/semantics/python_production.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
import ast
from collections import Counter
from dataclasses import dataclass
from functools import lru_cache
from types import SimpleNamespace
from typing import Mapping, TypeVar
from typing import Mapping, TypeVar, cast

from .inventory import SourceFile

Expand Down Expand Up @@ -137,20 +138,29 @@ def enum_members(source: SourceFile, symbol: str, *, strict: bool = False) -> di
return result


@lru_cache(maxsize=4096)
def _field_mentions(source: SourceFile) -> frozenset[str]:
"""Reuse parsed names, not another retained AST or a source-text filter."""
names: set[str] = set()
for node in ast.walk(_parsed(source)):
if isinstance(node, ast.Name):
names.add(node.id)
elif isinstance(node, ast.Attribute):
names.add(node.attr)
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
names.add(node.value)
elif isinstance(node, ast.keyword) and node.arg is not None:
names.add(node.arg)
return frozenset(names)


def python_literal_uses(source: SourceFile, field: str) -> set[str]:
"""Literal writes and direct dispatch operands; not alias/data-flow proof."""
tree = _parsed(source)
nodes = list(ast.walk(tree))
# Inspect parsed names/keys, including escaped strings and normalized
# identifiers. Parse errors must still fail even when the field is absent.
if not any(
isinstance(node, ast.Name) and node.id == field
or isinstance(node, ast.Attribute) and node.attr == field
or isinstance(node, ast.Constant) and node.value == field
or isinstance(node, ast.keyword) and node.arg == field
for node in nodes
):
# Parsing precedes absence: escaped keys and normalized identifiers count,
# and invalid syntax must fail even without a textual field mention.
if field not in _field_mentions(source):
return set()
tree = _parsed(source)

def reads(node: ast.AST) -> bool:
if isinstance(node, ast.Name):
Expand Down Expand Up @@ -188,7 +198,7 @@ def literals(node: ast.AST) -> set[str]:
# conditions and does not inspect strings containing sample source text.
rows = scan_python_production(source, field=field, enums={})
result = set().union(*(row.values for row in rows))
for node in nodes:
for node in ast.walk(tree):
if isinstance(node, ast.Compare):
for left, operator, right in zip(
[node.left, *node.comparators[:-1]], node.ops, node.comparators, strict=True,
Expand Down Expand Up @@ -222,9 +232,9 @@ def _qualified_bindings(source: SourceFile, tree: ast.Module, owners: Mapping[st
if value is None and modules is not None and alias.name in symbols:
# One unrenamed re-export hop through a tracked module binds the
# same owner; only names that are owner symbols are followed.
target = _tracked_module(module, modules)
if target is not None and target.path != source.path:
value = _reexported(target, alias.name, imports)
reexport_source = _tracked_module(module, modules)
if reexport_source is not None and reexport_source.path != source.path:
value = _reexported(reexport_source, alias.name, imports)
if value is not None:
bindings[name] = value
else:
Expand Down Expand Up @@ -323,11 +333,11 @@ def _module_functions(source: SourceFile, tree: ast.Module) -> dict[str, ast.Fun
# name some scope declares ``global`` and then stores is no longer reliably
# the ``def`` above; binding a call to that ``def`` would report the original
# body's returns for a function the module can swap at runtime.
for node in ast.walk(tree):
if not isinstance(node, ast.Global):
for global_node in ast.walk(tree):
if not isinstance(global_node, ast.Global):
continue
scope = _enclosing_scope(tree, node)
for name in node.names:
scope = _enclosing_scope(tree, global_node)
for name in global_node.names:
if name in defined and _stores_name(scope, name):
bound[name] += 1
functions = {name: node for name, node in defined.items()
Expand All @@ -338,14 +348,14 @@ def _module_functions(source: SourceFile, tree: ast.Module) -> dict[str, ast.Fun

def _index_value(node: ast.AST) -> str | int | None:
if isinstance(node, ast.Constant) and type(node.value) in (str, int):
return node.value
return cast(str | int, node.value)
if (isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub)
and isinstance(node.operand, ast.Constant) and type(node.operand.value) is int):
return -node.operand.value
return None


def _union(values: list[ast.AST]) -> ast.AST:
def _union(values: list[ast.expr]) -> ast.expr:
"""Fold several definitions of one local into a finite selection node.

The scan reports syntactic result possibilities, so a name written more than
Expand Down Expand Up @@ -407,6 +417,12 @@ def scan_python_production(
call_arguments = call_arguments or {}
calls = _qualified_bindings(source, tree, call_arguments, modules)
return_paths = return_paths or {}
# Every output form needs a field, registered return or qualified owner.
# Keep enum results and builder arguments even when the field is absent.
if not bindings and not calls and not return_functions and (
not field or field not in _field_mentions(source)
):
return []
module_functions = _module_functions(source, tree)
call_memo: dict[tuple[str, str], tuple[frozenset[str], tuple[str, ...]]] = {}
environments: dict[tuple[int, str], SimpleNamespace] = {}
Expand Down Expand Up @@ -481,7 +497,7 @@ def collect(node: ast.AST, loops: frozenset[int] = frozenset()) -> None:
if scope == '<module>':
nested_names -= local_owner_names | {owner.split('::')[1] for owner in call_arguments
if owner.split('::')[0] == source.path}
import_bound = set()
import_bound: set[str] = set()
for node in nodes:
if isinstance(node, (ast.Import, ast.ImportFrom)):
import_bound.update(alias.asname or alias.name.split('.')[0] for alias in node.names)
Expand Down Expand Up @@ -510,7 +526,7 @@ def collect(node: ast.AST, loops: frozenset[int] = frozenset()) -> None:
local_bindings = {k: v for k, v in bindings.items() if k not in shadows}
local_calls = {k: v for k, v in calls.items() if k not in shadows}

plain: dict[str, list[ast.AST]] = {}
plain: dict[str, list[ast.expr]] = {}
for node in nodes:
target = value = None
if (isinstance(node, ast.Assign) and len(node.targets) == 1
Expand All @@ -520,7 +536,7 @@ def collect(node: ast.AST, loops: frozenset[int] = frozenset()) -> None:
and node.value is not None):
target, value = node.target.id, node.value
if target is not None:
plain.setdefault(target, []).append(value)
plain.setdefault(target, []).append(cast(ast.expr, value))
declared = {name for node in nodes if isinstance(node, (ast.Global, ast.Nonlocal))
for name in node.names}
# Every store of the name must be one of those plain writes, so a value
Expand Down Expand Up @@ -772,7 +788,7 @@ def same_module_call(node: ast.Call, mode: str) -> tuple[set[str], bool] | None:
blocked(reason)
return set(values), bool(reasons)

def enum_object_value(node: ast.AST, seen: frozenset[str]) -> tuple[set[str], bool]:
def enum_object_value(node: ast.AST | None, seen: frozenset[str]) -> tuple[set[str], bool]:
node, seen = bound(node, seen)
if isinstance(node, ast.IfExp):
parts = [enum_object_value(value, seen) for value in (node.body, node.orelse)]
Expand Down Expand Up @@ -855,7 +871,7 @@ def scan_scope(body: list[ast.stmt], scope: str, parameters: set[str],
env = env if env is not None else environment(body, scope, parameters, call_shadows, own)
blockers = env.blockers

def record(node: ast.AST | None, form: str, location: ast.AST) -> None:
def record(node: ast.AST | None, form: str, location: ast.expr | ast.stmt) -> None:
# Reasons are read back by position: one scope's environment is
# shared with same-module call resolution, which may nest inside.
start = len(blockers)
Expand Down
1 change: 1 addition & 0 deletions skills/loopx-self-repair/references/repair-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ teaches a reusable control-plane lesson.
| `admission_timer_reentry_gap` | A positively admitted Todo with an unchanged, already-due timer immediately becomes writeback-only on same-Turn reentry before producing a result. | The original positive admission receipt, its retained resume/readiness facts and timestamp, current canonical Todo, exact binding, and File/SQLite replay. | Recovery classified every external wait as introduced after admission; today's due flag alone cannot establish when a wait was authored. | Freeze scalar resume/readiness facts at the first positive admission and compare them in the existing typed wait owner with microsecond timestamps. Continue only the unchanged runnable timer; later waits and missing/malformed legacy facts retain original closeout. Preserve identity, clocks, gates and one debit. Never clear a legitimate wait, rewrite receipts, fabricate progress or reset a retry clock to escape recovery. |
| `blocked_turn_retry_history_gap` | An exact blocked Turn can close without spending, but its open Todo is immediately selected again or loses its wait when other runs crowd the status display; a peer hard lease prevents the blocked agent from updating the Todo. | Exact Goal/Agent/Todo/Turn receipt, peer lease, canonical Todo validator, persisted retry due time, full run index versus compact recent runs and cached index digest, next-Turn selection and spend count. | The settlement had no bounded retry authority, or quota selection reconstructed it from truncated or stale status projection and tried to mutate a peer-gated Todo. | Persist one bounded retry on the blocked Turn receipt, retain it in the full-index semantic projection, and fence cached scheduler status against the current Run index before projecting it for the same agent. Keep the canonical Todo and terminal validator intact; prove replay, no spend, crowded history, stale cache, and real File/SQLite hard-lease readback. |
| `host_closeout_history_scan_amplification` | Quota entry approaches or exceeds its runtime timeout only on a long-lived Goal, especially when many prior Turns already have valid closeouts. | Turn and receipt counts, rollout-log and runs-index sizes, candidate count, per-stage elapsed time, declared runtime budget, and the same synthetic settled history before and after repair. | Preflight rebuilt append-order recency with repeated list searches, then each candidate reread, reparsed, and rescanned the complete event and run history. A larger timeout hid the multiplicative work without bounding it. | Parse each persisted source once, retain strict-versus-tolerant read semantics in the shared snapshot, build append-order and exact Turn/effect indexes, and reduce every candidate from that snapshot. Restore the ordinary runtime budget and cover a sufficiently large all-settled history that must traverse every candidate; never truncate history, skip identity validation, or raise the timeout as the repair. |
| `static_scan_repeats_module_analysis` | Required vocabulary or static checks approach their timeout as tracked modules and vocabulary queries grow. | Exact source/runtime, unchanged tracked-source population, complete production and unknown observations, phase CPU/elapsed time, native budget and memory footprint; escaped/normalized-name, invalid-syntax and source-change counterexamples. | Each field query rebuilds module/scope analysis even without a parsed field, registered return or qualified enum/builder. A source-substring shortcut would lose escaped strings and normalized identifiers. | Reuse bounded immutable parsed-name facts keyed by the complete source beside the existing analyzer. Parse before proving absence; retain enum, return, builder and unknown evidence. Compare complete observations and CPU, preserve hash-collision/source-change behavior, and rerun the full native check under its original budget. Retain failed or contended runs and disclose memory costs. |
| `turn_replay_recovery_semantic_gap` | A real Turn resumes from a safe Journal prefix, but `inspect-journal` presents `replay_legal=false` as if recovery were forbidden; scheduler-only or saved-Host-result recovery is especially misleading. | Journal integrity fields, replay decision, executor-adopted recovery decision, completed phase prefix, complete typed settlement identity, authoritative selected-Todo lineage, conditional Host Session Binding check, prepared-effect presence, and bounded recovery outcome. | Effect-free terminal replay and effectful executor recovery were collapsed into one user-facing decision even though the executor used separate status/phase rules; initial repairs also omitted either the settlement identity or its binding-to-Turn cross-check, allowing a drifted goal, agent, or canonical Todo to reach a later effect. | Keep replay legality and Journal consistency distinct. Make one typed recovery decision the source used by both executor and inspection; validate and cross-bind the canonical settlement goal, agent, Turn instance, binding, and effect id before authorization, including the selected Todo or adaptive primary Todo; project continue/resume phase/Host reinvocation/reason plus only participating checks, and persist a bounded planned-versus-actual audit. Cover identity and binding drift with zero-provider-call regressions. Preserve the existing prepared-effect readback owner and do not claim general exactly-once semantics. |
| `managed_chat_resume_turn_identity_gap` | A managed Chat Session resumes after its adapter becomes unhealthy, returns to `ready`, and clears `active_turn_id`, but the interrupted Turn remains nonterminal and unowned. | Pre-resume Session snapshot, post-prepare persisted Session, adapter health, interrupted Turn status and error code, final Session state. | Resume preparation cleared the persisted active Turn reference before adapter recovery, then recovery re-read only the mutated Session and lost the pre-resume Turn identity. | Carry the pre-mutation Turn id through the fresh closed-state check, terminalize that Turn as `failed/server_restarted` before restoring the Session, and cover unhealthy-adapter resume with a focused regression while retaining the per-Session lifecycle lock. |
| `dashboard_chat_stdio_encoding_gap` | Dashboard Chat completes ASCII turns but Codex app-server closes on non-ASCII input on Windows; direct stderr reports invalid UTF-8. | Failing multilingual request, app-server stderr, Python locale encoding, app-server `Popen` text-pipe options, and the same request under an explicit UTF-8 control. | The text-mode stdio pipe inherited the Windows system code page even though the app-server JSON-RPC transport requires UTF-8. | Set `encoding="utf-8"` explicitly on the app-server subprocess pipes and retain a focused launch-contract regression so host locale cannot change the protocol encoding. |
Expand Down
35 changes: 35 additions & 0 deletions tests/architecture/test_semantic_production.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,41 @@ def test_literal_scan_rejects_invalid_syntax_without_a_field_mention():
source = SourceFile('loopx/broken_probe.py', '.py', 'def broken(')
with pytest.raises(SyntaxError):
python_production.python_literal_uses(source, 'action')
with pytest.raises(SyntaxError):
python_production.scan_python_production(source, field='action', enums={})


@pytest.mark.parametrize('text,options,expected', [
("def choose():\n return 'run'", {'return_functions': frozenset({'choose'})},
{('return', frozenset({'run'}), False)}),
("class Action:\n RUN='run'\ndef choose():\n return Action.RUN",
{'enums': {'loopx/field_absence.py::Action': {'RUN': 'run'}}},
{('enum_result', frozenset({'run'}), False)}),
("def pack(result):\n return result\npack('run')",
{'call_arguments': {'loopx/field_absence.py::pack': {'result': 0}}},
{('call_argument', frozenset({'run'}), False)}),
("def choose(value):\n return value", {'return_functions': frozenset({'choose'})},
{('return', frozenset(), True)}),
])
def test_field_absence_preserves_registered_and_enum_evidence(text, options, expected):
source = SourceFile('loopx/field_absence.py', '.py', text)
assert 'action' not in text
rows = python_production.scan_python_production(source, **{'field': 'action', 'enums': {}, **options})
assert {(row.form, row.values, row.unresolved) for row in rows} == expected


def test_field_presence_tracks_source_changes_despite_hash_collisions():
class CollidingText(str):
def __hash__(self):
return 0

for text, expected in [
("other = 'wait'", set()),
(r"packet['\x61ction'] = 'run'", {'run'}),
("other = 'run'", set()),
]:
source = SourceFile('loopx/field_presence.py', '.py', CollidingText(text))
assert python_production.python_literal_uses(source, 'action') == expected


@pytest.mark.parametrize('first_text,second_text', [
Expand Down
Loading