Skip to content

fix(agent-api): make free-form pending questions answerable + unblock /answer - #1855

Closed
john-the-dev wants to merge 7 commits into
sonichi:mainfrom
john-the-dev:codex/fix-web-answer-response
Closed

john-the-dev wants to merge 7 commits into
sonichi:mainfrom
john-the-dev:codex/fix-web-answer-response

Conversation

@john-the-dev

@john-the-dev john-the-dev commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The web UI listed a pending question (Q1) but answering it failed with question Q1 not found or already answered. Two root causes behind the single "no response" symptom:

  • /answer marker gate. POST /answer skipped any section whose body lacked **Status:**/**Options:** markers, while GET /tasks/active lists marker-less free-form prose questions (the format the proactive loop and the CLAUDE.md "Pending decisions" protocol produce). The UI advertised a question the answer endpoint structurally refused -> 404. Fix: drop the marker gate, mirror the listing filter, and append a **Status:** Answered line when the matched section has none so it records and drops out of the next list. Q-numbering already aligned: both endpoints index the same re.split('^## ') sections.
  • Single-threaded server. The API ran on http.server.HTTPServer, so a slow GET /status (it shells out to health-check.py, about 10s) blocked concurrent /answer and /result requests. The answer could hang and the UI rendered nothing. Fix: switch to ThreadingHTTPServer.

Test plan

  • python3 tests/agent-api-answer-freeform.test.py — new regression: a marker-less prose question is answerable and an already-answered one still 404s.
  • python3 tests/agent-api-pending-questions.test.py — structural guard that the marker gate is gone from /answer; threading-server assertion; preamble/[RESOLVED]/resolved-text skips preserved.
  • python3 -m py_compile src/agent-api.py tests/agent-api-pending-questions.test.py tests/agent-api-answer-freeform.test.py.
  • git diff --check.
  • Failing-before evidence on origin/main: POST /answer {"id":"Q1"} returned 404 while GET /tasks/active listed Q1.
  • Live patched-server evidence: restarted local agent-api.py, appended a throwaway marker-less pending question, confirmed GET /tasks/active listed it as Q2, POST /answer returned 200, verified the answered status was written, then restored the pending file and removed the temporary answer task.

Reviewer notes

  • This is intentionally scoped to src/agent-api.py; it does not touch the separate front-end transcript persistence issue from the earlier web-chat diagnosis.
  • The live test used a temporary entry and restored workspace/hosts/Ruis-MacBook-Pro-2/pending-questions.md afterward.

Dependency: must be followed by #1928 (fix(agent-api): serialize voice_desired_state mutations under a lock) — that PR closes the race that switching to ThreadingHTTPServer introduces.

@john-the-dev

john-the-dev commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

Attribution: the fix in this PR was authored by codex — it wrote the /answer rewrite, the ThreadingHTTPServer switch, and the regression tests, and ran the first live patched-server test. This session verified, committed, pushed, and opened the PR at the owner's request, then re-ran a fresh non-destructive live check against the running server: a marker-less prose question (Q2) returned HTTP 200, and a concurrent /tasks/active returned in 34ms while a slow /status was in flight (threading confirmed). No real pending question was consumed.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Root cause is correct — GET /tasks/active listed marker-less free-form questions that POST /answer structurally refused, causing the UI to advertise an unanswerable Q1. Dropping the marker gate from /answer and mirroring the same filter closes the gap. The free-form fallback (appending **Status:** Answered when no status line exists) handles the write correctly. ThreadingHTTPServer bonus is a real reliability win — a long /result poll can no longer starve /answer. Tests cover both the freeform-answerable and already-answered-still-404s cases. Approve.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both root causes properly addressed. The section reconstruction via new_sections mirrors the GET listing exactly — same re.split(r"^## ") split, same ## prefix on rejoin, so Q-numbering stays in sync. The else: branch that appends Status: Answered when the section has no existing marker correctly extends the answerable set to free-form prose questions without changing their content. ThreadingHTTPServer is the right fix for a server that shells out to health-check.py during GET /status — the single-thread deadlock was genuinely blocking answering. ✓

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix. The previous code skipped free-form sections (no Status:/Options:), which caused 404 on answering visible questions. The reworked loop correctly appends Status: Answered to sections that lack the field. ThreadingHTTPServer upgrade is also correct — blocking HTTP on long-running writes was a latent issue. LGTM.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex triage: the production half (free-form pending questions answerable + ThreadingHTTPServer unblocking) is a real pain-point fix and can merge - but two test files are source-greps (assert substrings of agent-api.py source, including grepping for the literal ThreadingHTTPServer string), an auto-fail anti-pattern per the triage test standard. Keep tests/agent-api-answer-freeform.test.py (good behavioral test), drop or rewrite the two grep files as behavior, then this merges. - Maddy (MBP)

@liususan091219 liususan091219 added the request-change Has an active CHANGES_REQUESTED review label Jul 3, 2026
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Addressed in e7492b3:

Dropped tests/agent-api-pending-questions.test.py (source-grep, all 5 assertions read agent-api.py source text).

Extended tests/agent-api-answer-freeform.test.py with 4 behavioral tests against a live ThreadingHTTPServer:

  1. test_freeform_question_visible_in_tasks_active — free-form ## section without Status:/Options: markers appears in GET /tasks/active
  2. test_resolved_question_excluded_from_tasks_active — [RESOLVED …] sections skipped
  3. test_preamble_not_counted_as_question — text before first ## not counted
  4. test_concurrent_requests_do_not_block — 2 simultaneous requests both return 200 (tests ThreadingHTTPServer behaviour rather than asserting the class name in source)

6/6 pass.

@github-actions

github-actions Bot commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

❌ Diff coverage BELOW the 95% bar — uncovered changed lines listed below. Whole-tree (informational): 46%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/agent-api.py (48.4%): Missing lines 416-419,425-433,440-445,447-454,456-458,461-468,475-481,483,864-865

Summary

  • Total: 93 lines
  • Missing: 48 lines
  • Coverage: 48%

src/agent-api.py

Lines 412-423

  412             claude_ok = subprocess.run(["/usr/bin/pgrep", "-f", "claude.*sutando-core"], capture_output=True).returncode == 0
  413             with _task_history_lock:
  414                 # Scan disk for active tasks, update history (preserve existing text)
  415                 for f in sorted(TASK_DIR.glob("*.txt"), key=lambda p: p.stat().st_mtime, reverse=True)[:10]:
! 416                     task_id = f.stem
! 417                     content = f.read_text()
! 418                     task_line = ""
! 419                     source_line = ""
  420                     # Capture the first `source:` and first `task:` regardless of
  421                     # field order — voice/chat tasks put `source:` before `task:`,
  422                     # but discord/slack tasks put `task:` first. The `not …` guards
  423                     # keep the real header `source:` from being overridden by any

Lines 421-437

  421                     # field order — voice/chat tasks put `source:` before `task:`,
  422                     # but discord/slack tasks put `task:` first. The `not …` guards
  423                     # keep the real header `source:` from being overridden by any
  424                     # `source:` line inside the task body (#1781 review, sonichi).
! 425                     for line in content.splitlines():
! 426                         if not source_line and line.startswith("source:"):
! 427                             source_line = line[7:].strip()
! 428                         elif not task_line and line.startswith("task:"):
! 429                             task_line = line[5:].strip()
! 430                         if task_line and source_line:
! 431                             break
! 432                     result_file = RESULT_DIR / f.name
! 433                     existing = task_history.get(task_id, {})
  434                     # Look for the result in three places, in priority order:
  435                     # (1) live results/ dir, (2) prior in-memory history (covers
  436                     # the case where the bridge has already archived the file),
  437                     # (3) results/archive/<YYYY-MM>/. Without (3), an agent-api

Lines 436-472

  436                     # the case where the bridge has already archived the file),
  437                     # (3) results/archive/<YYYY-MM>/. Without (3), an agent-api
  438                     # restart loses every prior task's result and the web UI
  439                     # shows them all as "working" with no body.
! 440                     archived_file = None
! 441                     for month_dir in (RESULT_DIR / "archive").glob("*/"):
! 442                         candidate = month_dir / f.name
! 443                         if candidate.exists():
! 444                             archived_file = candidate
! 445                             break
  446                     if result_file.exists():
! 447                         status = "done"
! 448                         result_text = result_file.read_text().strip()
! 449                     elif existing.get("status") == "done" or existing.get("result"):
! 450                         status = "done"
! 451                         result_text = existing.get("result", "")
! 452                     elif archived_file is not None:
! 453                         status = "done"
! 454                         result_text = archived_file.read_text().strip()
  455                     else:
! 456                         status = "working"
! 457                         result_text = ""
! 458                     task_history[task_id] = {"status": status, "text": task_line or existing.get("text", task_id), "time": f.stat().st_mtime, "result": result_text, "source": source_line or existing.get("source", "")}
  459                 # Also check for result files without task files (already cleaned up)
  460                 for f in sorted(RESULT_DIR.glob("task-*.txt"), key=lambda p: p.stat().st_mtime, reverse=True)[:10]:
! 461                     task_id = f.stem
! 462                     if task_id not in task_history:
! 463                         result_content = f.read_text().strip()
! 464                         display_text = result_content.split('\n')[0][:80] if result_content else task_id
! 465                         task_history[task_id] = {"status": "done", "text": display_text, "time": f.stat().st_mtime, "result": result_content}
! 466                     elif task_history[task_id].get("status") != "done":
! 467                         task_history[task_id]["status"] = "done"
! 468                         task_history[task_id]["result"] = f.read_text().strip()
  469                 # Reconcile stale entries: if task file is gone and result exists, mark done;
  470                 # if task file is gone, no result, and older than 5 min, remove from history
  471                 import time as _time
  472                 stale_ids = []

Lines 471-487

  471                 import time as _time
  472                 stale_ids = []
  473                 for tid, tdata in list(task_history.items()):
  474                     if tdata.get("status") == "working":
! 475                         task_file = TASK_DIR / f"{tid}.txt"
! 476                         result_file = RESULT_DIR / f"{tid}.txt"
! 477                         if result_file.exists():
! 478                             tdata["status"] = "done"
! 479                             tdata["result"] = result_file.read_text().strip()
! 480                         elif not task_file.exists() and _time.time() - tdata.get("time", 0) > 300:
! 481                             stale_ids.append(tid)
  482                 for tid in stale_ids:
! 483                     del task_history[tid]
  484                 # Return most recent 10 from history
  485                 sorted_tasks = sorted(task_history.items(), key=lambda x: x[1].get("time", 0), reverse=True)[:10]
  486                 tasks = [{"id": tid, **tdata} for tid, tdata in sorted_tasks]
  487             # Parse pending questions

Lines 860-869

  860                 tid = data.get("taskId", "")
  861                 result = data.get("result", "")
  862                 with _task_history_lock:
  863                     if tid in task_history:
! 864                         task_history[tid]["status"] = "done"
! 865                         task_history[tid]["result"] = result
  866                     else:
  867                         task_history[tid] = {"status": "done", "text": result[:80], "time": datetime.now().timestamp(), "result": result}
  868                 self.send_json(200, {"ok": True})
  869             except Exception:

john-the-dev added a commit to john-the-dev/sutando that referenced this pull request Jul 7, 2026
…nstruction

Adds 4 tests targeting the 5 missing lines in the POST /answer section
reconstruction loop (lines 864, 870-871, 873, 881):

- test_answer_skips_resolved_section_in_loop: [RESOLVED] section before
  the target question is preserved unchanged (line 873)
- test_answer_replaces_status_waiting_marker: **Status:** Waiting is
  replaced in-place rather than appended (line 881)
- test_answer_with_empty_section_preserved: empty ## section is passed
  through the loop unchanged (line 864)
- test_answer_skips_sub_heading_section: section whose title starts with
  '#' (sub-heading mis-parse guard) is skipped, loop continues (lines 870-871)

All 10 tests pass. Targets the diff-cover >= 95% CI gate for PR sonichi#1855.
Reviewer asked to keep tests/agent-api-answer-freeform.test.py (behavioral)
and drop the source-grep files — tests/agent-api-pending-questions.test.py
was already deleted in an earlier commit on this branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QB3qZQrwponfum2JFNvHeh
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

@liususan091219 Thanks for the triage — addressed:

  • tests/agent-api-pending-questions.test.py (source-grep) was already deleted in this branch. Confirmed it's gone.
  • Added 4 new behavioral tests to tests/agent-api-answer-freeform.test.py covering the 5 missing lines (864, 870-871, 873, 881) in the POST /answer section reconstruction loop:
    • [RESOLVED] section before target question preserved (line 873)
    • **Status:** Waiting replaced in-place, not appended (line 881)
    • Empty ## section passed through unchanged (line 864)
    • Sub-heading guard (title.startswith('#')) skipped cleanly (lines 870-871)

CI should now pass the diff-cover ≥95% gate (was 76.2% → all 21 new lines covered).

Comment thread src/agent-api.py
if __name__ == "__main__":
bind = os.environ.get("AGENT_API_BIND", "127.0.0.1")
server = http.server.HTTPServer((bind, PORT), Handler)
server = http.server.ThreadingHTTPServer((bind, PORT), Handler)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review: this changes the live server to ThreadingHTTPServer, but the handler still mutates process-wide globals like task_history without any synchronization. That means concurrent /task-done, /tasks/active, and cleanup requests can interleave updates and lose or reorder task state. The new test only proves two parallel GETs complete; it does not cover the shared mutable paths that become concurrent with this server swap.

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Automated review:

src/agent-api.py:1057 switches the server to ThreadingHTTPServer, but voice_desired_state is still a naked module-global that /voice/toggle and /voice/set mutate without any synchronization (src/agent-api.py:770-778, plus the toggle branch just above). Under concurrent requests two toggles can both read "disconnected" and both write "connected", so the final state is wrong. This branch therefore introduces a new race unless it also serializes those handlers (or otherwise makes the state update atomic).

fix(agent-api): serialize voice_desired_state mutations under a lock (#1928) appears to address exactly this follow-on.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Exactly right — #1928 is the companion fix that wraps voice_desired_state reads and writes under a threading.Lock(). The two PRs form a sequence: #1855 introduces ThreadingHTTPServer (concurrent request handling), #1928 closes the race that creates. Intended merge order: #1855 → #1928. I've added a note to the #1855 PR description to make this dependency explicit.

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Automated review:

src/agent-api.py:1065 switches the server to ThreadingHTTPServer, but the same patch still mutates module globals like voice_desired_state lock-free in POST /voice/toggle and POST /voice/set (src/agent-api.py:763-778). Under two concurrent requests the read-modify-write can interleave and leave the voice state in the wrong value, which is a real regression from the previous single-threaded server. This PR should either keep the single-threaded server or serialize those shared-state updates before landing the threading change.

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Automated mergeability review (2026-07-09)

Signal: NOT MERGEABLE

Current blocker(s):

  • Existing review decision is CHANGES_REQUESTED; requested changes need to be resolved.
  • Branch is behind current main; update/merge main and rerun checks before merging.

This is a conservative backlog triage signal. After the blockers are cleared, please rerun checks and request a focused review.

@liususan091219
liususan091219 self-requested a review July 11, 2026 13:50

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Triage change-request (owner-approved batch, 2026-07-12 sweep). Conditions found on the current diff:

  • live-test evidence required (voice/vision policy) — Voice toggle/set runtime changed; add live voice repro or transcript evidence.

Justification: kb triage standards (source-grep test standard, voice/vision live-test policy, necessity/coverage checks) — see sonichi/sutando-pr-triage kb/.
Address the asks and push; re-request review to clear this. If a finding is wrong, reply with the counter-evidence and it will be withdrawn.

cr-reason: live_test_required

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Triage change-request (owner-approved batch, 2026-07-12 sweep). Conditions found on the current diff:

  • live-test evidence required (voice/vision policy) — Voice toggle/set runtime changed; add live voice repro or transcript evidence.

Justification: kb triage standards (source-grep test standard, voice/vision live-test policy, necessity/coverage checks) — see sonichi/sutando-pr-triage kb/.
Address the asks and push; re-request review to clear this. If a finding is wrong, reply with the counter-evidence and it will be withdrawn.

cr-reason: live_test_required

@liususan091219 liususan091219 added cr:live-test-required voice Voice / vision / wake runtime and removed cr:live-test-required voice Voice / vision / wake runtime labels Jul 12, 2026

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto re-review (codex, owner-directed) — judging the 2026-07-03 change-request against the current diff:

Verdict: REMAINS

  • Current diff shows only src/agent-api.py changes; it does not drop or rewrite the two source-grep test files called out in the change-request.
  • It still adds/keeps a literal ThreadingHTTPServer string in source comments, so a test grepping for that literal would remain the same anti-pattern.
  • The behavioral production fixes may be present, but the requested test cleanup is not evidenced in this diff.

The change-request stays until the remaining asks land.

@liususan091219 liususan091219 added the live-test-requested A change-request asks for live-test/repro evidence (voice/vision/runtime policy) label Jul 13, 2026
@liususan091219
liususan091219 dismissed stale reviews from themself July 13, 2026 02:56

Withdrawn per owner policy 2026-07-13: live-test asks apply only to interactive voice/UI/phone/vision behavior changes without already-provided evidence. This change is plumbing/ops, not interactive runtime.

@liususan091219 liususan091219 added bug Something isn't working and removed live-test-requested A change-request asks for live-test/repro evidence (voice/vision/runtime policy) bug Something isn't working labels Jul 13, 2026

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto re-review (author pushed after the change-request):

  • ask 1: REMAINS — Current diff shows only src/agent-api.py changes; no evidence the two source-grep test files were

Remaining asks above — the change-request stays until they land.

@liususan091219

Copy link
Copy Markdown
Collaborator

Daily reminder — commits landed after the 2026-07-03 change-request, but the auto re-review found asks still open (see the re-review comment above for exactly what remains). Pushing those closes this out; ~2 weeks without them routes to final-notice per the triage policy.

nudge: cr-remains day9

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto re-review (author pushed after the change-request):

  • ask 1: REMAINS — Current diff still changes only src/agent-api.py; no evidence that the two source-grep test files

Remaining asks above — the change-request stays until they land.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto re-review (author pushed after the change-request):

  • ask 1: ADDRESSED — current diff does not remove tests/agent-api-answer-freeform.test.py.
  • ask 2: REMAINS — current diff only changes src/agent-api.py; no deletion or behavioral rewrite of the two source-gr

Remaining asks above — the change-request stays until they land.

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional blocking ask — live-test evidence (owner's web-UI standard). This changes the user-facing answer flow (dashboard lists a free-form pending question → user answers → answer lands), which is exactly the path that failed live before this fix. Unit tests aren't sufficient proof here: attach a short real-browser recording showing a marker-less free-form question listed in the dashboard being answered successfully end-to-end (and the long-poll case surviving). Together with the earlier ask still open (remove/rewrite the two source-grep test files). cr-reason: author_live_test_required

@liususan091219 liususan091219 added the live-test-requested A change-request asks for live-test/repro evidence (voice/vision/runtime policy) label Jul 14, 2026
@john-the-dev john-the-dev added the ag2product AG2 product related (packages, PyPI, task-relay) label Jul 14, 2026
john-the-dev and others added 5 commits July 14, 2026 11:10
… /answer

Web UI showed a question (Q1) it then refused to answer with
"question Q1 not found or already answered".

Two root causes behind the one "no response" symptom:
- POST /answer skipped any section lacking **Status:**/**Options:** markers,
  but GET /tasks/active lists marker-less free-form prose questions (the format
  the proactive loop and the "Pending decisions" protocol produce). So the UI
  advertised a question the answer endpoint structurally refused. Drop the
  marker gate and mirror the listing filter; append a Status line when the
  matched section has none, so it records and drops out of the next list.
- The API ran on single-threaded HTTPServer, so a slow GET /status
  (health-check subprocess, ~10s) blocked concurrent /answer and /result —
  the answer request hung and the UI showed nothing. Switch to
  ThreadingHTTPServer.

Adds a freeform-answer regression test and a threading-server assertion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… suite

Maddy's CHANGES_REQUESTED: agent-api-pending-questions.test.py was a source-
grep (assertIn on agent-api.py source text), auto-fail per triage standard.

Drop it. Extend agent-api-answer-freeform.test.py with 4 new behavioral tests
against a live ThreadingHTTPServer:

- freeform_question_visible_in_tasks_active: free-form ## section without
  Status:/Options: markers appears in GET /tasks/active
- resolved_question_excluded: [RESOLVED …] sections are skipped
- preamble_not_counted: text before first ## is not a question entry
- concurrent_requests_do_not_block: 2 simultaneous requests both return 200
  (verifies ThreadingHTTPServer behaviour rather than asserting the class name)

6/6 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYS5HYbYLquVinohLrCRMU
…nstruction

Adds 4 tests targeting the 5 missing lines in the POST /answer section
reconstruction loop (lines 864, 870-871, 873, 881):

- test_answer_skips_resolved_section_in_loop: [RESOLVED] section before
  the target question is preserved unchanged (line 873)
- test_answer_replaces_status_waiting_marker: **Status:** Waiting is
  replaced in-place rather than appended (line 881)
- test_answer_with_empty_section_preserved: empty ## section is passed
  through the loop unchanged (line 864)
- test_answer_skips_sub_heading_section: section whose title starts with
  '#' (sub-heading mis-parse guard) is skipped, loop continues (lines 870-871)

All 10 tests pass. Targets the diff-cover >= 95% CI gate for PR sonichi#1855.
Reviewer asked to keep tests/agent-api-answer-freeform.test.py (behavioral)
and drop the source-grep files — tests/agent-api-pending-questions.test.py
was already deleted in an earlier commit on this branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QB3qZQrwponfum2JFNvHeh
ThreadingHTTPServer serves each request on its own thread, leaving the
read-modify-write of the global voice_desired_state in POST /voice/toggle
and /voice/set racing lock-free. Add a module-level threading.Lock and
wrap the mutations; capture the resulting value locally so send_json I/O
runs outside the critical section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFp2p6XUSAQ1eAKKZeiZPt
The Lock guard added in 4999389 introduced 8 uncovered changed lines
(the `with _voice_state_lock:` blocks in POST /voice/toggle at 770-773
and POST /voice/set at 783-786), dropping diff coverage to 74.2% and
failing the >=95% python gate.

Add tests/agent-api-voice-state.test.py — matching the live
ThreadingHTTPServer harness used by agent-api-answer-freeform.test.py —
which drives /voice/toggle (flip both directions), /voice/set (explicit
state, default-to-disconnected, invalid-body 400), and concurrent
toggles. This executes both critical sections, taking diff coverage on
src/agent-api.py to 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFp2p6XUSAQ1eAKKZeiZPt
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Rebased onto latest main (CONFLICTING → resolved) and confirming the source-grep tests are already gone.

Rebase: the branch is rebased onto origin/main. The only conflict was in src/agent-api.py at the server-setup line — kept main's ThreadingHTTPServer comment block (the #1921 fix). Branch is now mergeable.

source-grep tests (Maddy's ask): already addressed on-branch by commit e7492b3 ("test(agent-api): replace source-grep structural tests with behavioral suite"), which predated the re-request. The two remaining test files are behavioral, not source-greps — each imports agent-api as a module, spins up a real ThreadingHTTPServer on 127.0.0.1:0, and drives it over HTTP:

  • tests/agent-api-answer-freeform.test.py — 10 tests (the one you flagged as good): POST /answer to a marker-less free-form question lands, [RESOLVED] sections don't leak into /tasks/active, concurrent GET /tasks/active both complete.
  • tests/agent-api-voice-state.test.py — 5 tests: POST /voice/toggle flips state under the lock, parallel toggles both complete.

Live run just now on the rebased head:

tests/agent-api-answer-freeform.test.py … Ran 10 tests … OK
tests/agent-api-voice-state.test.py …      Ran 5 tests …  OK

Neither reads agent-api.py as text or asserts source substrings — the ThreadingHTTPServer references are the tests instantiating the server, not grepping for the string.

Remaining (author_live_test_required): the real-browser recording of the dashboard free-form-answer flow is the one thing I can't produce headlessly. I can prep exact repro steps so a recording is quick, or the owner can capture it — flagging it as the sole open item. Re-requesting review on the code + tests.

@john-the-dev
john-the-dev force-pushed the codex/fix-web-answer-response branch from dcc6a52 to 9c41512 Compare July 14, 2026 18:11
@liususan091219 liususan091219 removed the ag2product AG2 product related (packages, PyPI, task-relay) label Jul 14, 2026
@jsun-m

jsun-m commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Heads-up — I hit this same 404 and, before finding this PR, wrote a fix for it in #2103. I couldn't push to this branch (fork owned by @john-the-dev), so it's a separate PR rather than a push here. Flagging it so you're not blindsided and so a maintainer can close whichever is redundant.

Where they agree: dropping the **Status:**/**Options:** marker gate from POST /answer, and recording the answer as a **Status:** Answered line so the section drops out of the next listing.

Where #2103 goes further — two things that still 404 (or mis-fire) with the gate alone:

  1. Positional ids. This PR keeps Q<split-index> on the reasoning that "both endpoints index the same re.split('^## ') sections". That holds within a single snapshot, but the ids are minted on GET and consumed on a later POST, and the agent rewrites pending-questions.md between the two — it resolves questions, appends new ones, and reorders. When a section lands above the one you were shown, Q3 now names its neighbour: 404 if the new section is filtered out, or a silent resolve of the wrong question if it isn't. fix(agent-api): make listed pending questions answerable (POST /answer 404s on every id) #2103 derives the id from the question title, so it survives the file moving underneath it.
  2. The # Resolved divider. GET /tasks/active never truncates at it (check-pending-questions.py:95 does), so archived entries are listed as open and offered for answering. On my file that was 9 of 22 sections.

Also FYI: the ThreadingHTTPServer half of this PR is already on main, so that part is a no-op now.

Happy either way — if you'd rather land this one, the two deltas above are easy to lift from #2103 and I'll close mine.

liususan091219 and others added 2 commits July 14, 2026 16:44
Addresses qingyun-wu's sonichi#1855 review: the server runs on ThreadingHTTPServer
(one thread per request), so the module-global task_history mutated by GET
/tasks/active (rebuild + stale-entry cleanup) and POST /task-done raced —
concurrent requests could interleave read-modify-writes or hit "dictionary
changed size during iteration". Add _task_history_lock and hold it across
both critical sections (mirrors the existing _voice_state_lock).

New deterministic test agent-api-task-history-concurrency.test.py instruments
the lock and asserts both mutating endpoints enter the critical section, plus
a concurrent no-lost-updates smoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSbiwh4YK8grtvzX28yPZb
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Pushed 615e032 addressing the outstanding asks.

1. Concurrency (qingyun-wu) — fixed

Production runs on ThreadingHTTPServer (one thread/request), and task_history (module global) was mutated by GET /tasks/active (rebuild + stale-entry cleanup) and POST /task-done without synchronization — concurrent requests could interleave read-modify-writes or hit "dictionary changed size during iteration". Added _task_history_lock and hold it across both critical sections (mirrors the existing _voice_state_lock).

New deterministic test tests/agent-api-task-history-concurrency.test.py instruments the lock and asserts BOTH mutating endpoints enter the critical section, plus a concurrent no-lost-updates smoke. (The "dict changed size" race is timing-dependent and won't reproduce deterministically under the GIL, so I proved the guard by lock-entry rather than a flaky hammer.)

2. Source-grep test files — already addressed on the current head

The two source-grep files from the original CR are gone: tests/agent-api-pending-questions.test.py was deleted, and the two shipped test files are behavioral — both drive a live ThreadingHTTPServer with real HTTP round-trips (urlopen), zero source-text asserts:

  • tests/agent-api-answer-freeform.test.py — real POST /answer + GET /tasks/active
  • tests/agent-api-voice-state.test.py — real POST /voice/toggle + /voice/set

(The auto-review's "only src/agent-api.py changed" was stale — the diff includes these test files.)

3. Live-test evidence (author_live_test_required) — attached

Ran the answer flow end-to-end against the actual agent-api server on this machine — the exact marker-less free-form path that 404'd before the fix:

### 1. GET /tasks/active — marker-less question listed?
   questions listed: [('Q1', 'Should we enable the launchd supervisor on boot?')]
### 2. POST /answer Q1 — the call that 404'd on every id before the fix
   HTTP 200 -> {'ok': True, 'id': 'Q1', 'answer': 'yes, enable it'}
### 3. pending-questions.md after — answer persisted
   contains '**Status:** Answered — yes, enable it': True
   answer task file emitted: ['answer-Q1-1784067479363.txt']
### 4. answering it again now 404s (no double-answer)
   HTTP 404 (correctly refused — already answered)

A marker-less free-form question is listed → answered end-to-end → persisted with an honest **Status:** → re-answer correctly 404s. This is server-level (the web UI calls these same two endpoints); happy to also capture a browser clip if the server-level trace isn't sufficient. Re-requesting review.

@john-the-dev
john-the-dev requested a review from qingyun-wu July 14, 2026 22:20
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Closing per owner decision — superseded by #2103 (@jsun-m), which fixes the same free-form pending-question answer 404 more completely (unified parser, stable title-hash ids, E2E + unit tests, 95% coverage gate) and is already approved. Consolidating on #2103 to avoid two PRs touching the same answer-flow code. The task_history concurrency hardening explored here (guarding the shared dict under ThreadingHTTPServer) can be re-proposed as a focused standalone PR if still wanted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

live-test-requested A change-request asks for live-test/repro evidence (voice/vision/runtime policy) request-change Has an active CHANGES_REQUESTED review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants