PR #3 — v0.2.1: The Correctness Tax

PR #3 — v0.2.1: The Correctness Tax

PR: fix: synlynk v0.2.1 — correctness patch
Merged: 2026-05-17 (same day as v0.2.0)
Tests: 47 passing (1 added)


The Broader Goal at This Point

Same as v0.2.0: reliable solo workflow. The watch daemon and checkpoint were the new capabilities. The goal was to validate them in real use and iterate.

What real use immediately revealed was that the kernel had critical correctness bugs — not edge cases, but primary-path failures that made the tool wrong in ways that could mislead or silently misbehave.


What This PR Fixed

Bug 1: Exit codes were silently swallowed

exec_command() returned nothing. main() called sys.exit() with 0 unconditionally after exec_command() returned, regardless of what the child process returned.

This means: synlynk exec python3 -c 'import sys; sys.exit(7)' → shell reports exit code 0.

This is not a minor inconvenience. It breaks every CI/CD pipeline that wraps AI CLI invocations with synlynk and expects the exit code to propagate correctly. A wrapped tool that exits non-zero (signaling failure) appears to succeed to the shell and to any orchestration layer above it.

The fix:

def exec_command(cmd_args):
    # ... setup ...
    proc = subprocess.Popen(cmd_args)
    proc.wait()
    return proc.returncode  # was: return (nothing)

def main():
    # ...
    code = exec_command(args.cmd_args)
    sys.exit(code)  # was: sys.exit(0)

The test added for this:

def test_exec_command_propagates_exit_code(project_dir):
    code = exec_command(["python3", "-c", "import sys; sys.exit(7)"])
    assert code == 7

Simple, direct, unambiguous. This test cannot pass with the old implementation.

Bug 2: parse_costs_md() read the wrong column

project-docs/costs.md has a 6-column schema:

| Date | User | Requests | Tokens (In/Out) | Estimated Cost (USD) | Summary |

Column indices after splitting on | and stripping:

  • parts[1] = Date
  • parts[2] = User
  • parts[3] = Requests
  • parts[4] = Tokens
  • parts[5] = Estimated Cost USD ← correct
  • parts[6] = Summary ← what the old code read

parse_costs_md() was reading parts[6] — the Summary column, which contains free text like "Auth refactor, 3 PRs". Parsing a free-text string as a float returns 0.0 for every row. The function was silently returning (0.0, N) for all inputs.

This meant:

  • synlynk status always showed $0.00 cumulative spend
  • Budget alerts never fired
  • The 80% budget warning was permanently dead

The fix was a one-character change (parts[5] not parts[6]), but the impact was the entire cost tracking subsystem being inoperative from v0.2.0's release.

The conftest.py fixture also had to be updated to match the real schema. The old fixture had 5 columns; the real schema has 6. Tests that ran against the wrong fixture were testing the wrong column and passing regardless.

Bug 3: Dead functions in the module

Three functions — log_telemetry(), extract_tokens(), and update_costs() — existed in bin/synlynk.py but were never called. They had been superseded by log_telemetry_event() and the manual cost tracking model when exec_command switched to Popen without capture.

Dead code in a single-file architecture is more dangerous than in a multi-file one. There is no module boundary to signal "this is not the active implementation." A contributor reading bin/synlynk.py and finding extract_tokens() would reasonably assume it is called somewhere and worth understanding. It is not.

Removed completely.

Bug 4: install.sh version string was wrong

install.sh had VERSION="1.2.0-lite" hardcoded — a relic from an earlier naming scheme. The actual CLI version was 0.2.0. The upgrade check compares this version against GitHub releases, so a mismatch here would cause the upgrade checker to either always or never report a new version.


The Deeper Lesson

v0.2.1 shipped on the same day as v0.2.0, because real use surfaced three critical bugs within hours of the initial release.

The pattern this establishes:

Ship small, test in reality. The test suite had 46 tests covering the new behavior. None of them caught the exit code swallowing, because the tests called exec_command() directly and checked its return value — but the test for "does synlynk exec propagate exit codes to the shell" was missing. Unit tests verify internal behavior; they do not verify end-to-end CLI behavior.

Correctness bugs compound in an agentic workflow. The exit code bug is especially dangerous in a multi-agent context: if synlynk exec gemini returns 0 when Gemini itself exited 1, then any automation that checks the synlynk exit code to decide whether to proceed will proceed incorrectly. The safeguard fails silently at exactly the moment it should trigger.

The cost parser bug is similarly dangerous: a budget that appears always at $0.00 is a budget that provides no protection. The entire "budget limit + 80% warning" feature was decorative while this bug existed.

Dead code in a harness is a hazard. A harness is read by contributors who are trying to understand the system. Dead functions create false mental models. In extract_tokens(), for example, a contributor might conclude that synlynk does automatic token extraction — which was the original design but is not the current implementation. Removing it is not cleanup; it is correctness.


What This Achieved on the Path to Autonomy

Small PR, large implications for autonomous operation:

  1. Exit code propagation is foundational for autonomous dispatch. In v0.7.0, synlynk dispatch routes jobs to agents and monitors their outcomes. The outcome of an agent invocation is its exit code. If exit codes are swallowed, the dispatch layer cannot distinguish success from failure. Every autonomous workflow depends on exit code propagation working correctly.

  2. Cost tracking must be trustworthy. The agentic PM hierarchy (Arc → Phase → Epic → Story) tracks estimated_tokens as the routing currency. Budget limits per story and per phase depend on cost tracking being accurate. A cost parser that always returns $0.00 makes the entire budget management subsystem fictional.

  3. The single-file discipline is maintained. The removal of dead functions is a commitment: bin/synlynk.py contains exactly the functions that are called. No more. This discipline becomes more important as the file grows. By v0.4.0, the file has migrate() and start() and many more functions — but each of them is live code on a real call path.


Strategic Note

No strategic shift in this PR. The goal was unchanged. The lesson was: correctness is not a later concern. In a harness that wraps autonomous tools, a correctness bug is not contained by a test failure — it propagates into the decisions made by the tools that depend on it.

Ship the kernel. Fix the kernel. Then build on it.


Next: PR #23/#24 — v0.2.2: Attribution in a Polyglot World — the first signal that multi-agent attribution is harder than it looks.