Repository navigation
fix(agent-api): make free-form pending questions answerable + unblock /answer - #1855
john-the-dev wants to merge 7 commits into
Conversation
|
Attribution: the fix in this PR was authored by codex — it wrote the |
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
✅ 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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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)
|
Addressed in e7492b3: Dropped Extended
6/6 pass. |
Coverage Gate❌ Diff coverage BELOW the 95% bar — uncovered changed lines listed below. Whole-tree (informational): 46%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
src/agent-api.pyLines 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 anyLines 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-apiLines 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 questionsLines 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: |
…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
|
@liususan091219 Thanks for the triage — addressed:
CI should now pass the diff-cover ≥95% gate (was 76.2% → all 21 new lines covered). |
| 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) |
There was a problem hiding this comment.
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.
|
Automated review:
|
|
Exactly right — #1928 is the companion fix that wraps |
|
Automated review:
|
|
Automated mergeability review (2026-07-09) Signal: NOT MERGEABLE Current blocker(s):
This is a conservative backlog triage signal. After the blockers are cleared, please rerun checks and request a focused review. |
liususan091219
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.pychanges; it does not drop or rewrite the two source-grep test files called out in the change-request. - It still adds/keeps a literal
ThreadingHTTPServerstring 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.
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
left a comment
There was a problem hiding this comment.
Auto re-review (author pushed after the change-request):
- ask 1: REMAINS — Current diff shows only
src/agent-api.pychanges; no evidence the two source-grep test files were
Remaining asks above — the change-request stays until they land.
|
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.
|
liususan091219
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
… /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
|
Rebased onto latest main (CONFLICTING → resolved) and confirming the source-grep tests are already gone. Rebase: the branch is rebased onto source-grep tests (Maddy's ask): already addressed on-branch by commit
Live run just now on the rebased head: Neither reads agent-api.py as text or asserts source substrings — the 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. |
dcc6a52 to
9c41512
Compare
|
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 Where #2103 goes further — two things that still 404 (or mis-fire) with the gate alone:
Also FYI: the 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. |
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
|
@cla-assistant check |
|
Pushed 1. Concurrency (qingyun-wu) — fixedProduction runs on New deterministic test 2. Source-grep test files — already addressed on the current headThe two source-grep files from the original CR are gone:
(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) — attachedRan 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: A marker-less free-form question is listed → answered end-to-end → persisted with an honest |
|
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. |
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:/answermarker gate.POST /answerskipped any section whose body lacked**Status:**/**Options:**markers, whileGET /tasks/activelists 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:** Answeredline when the matched section has none so it records and drops out of the next list. Q-numbering already aligned: both endpoints index the samere.split('^## ')sections.http.server.HTTPServer, so a slowGET /status(it shells out tohealth-check.py, about 10s) blocked concurrent/answerand/resultrequests. The answer could hang and the UI rendered nothing. Fix: switch toThreadingHTTPServer.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.origin/main:POST /answer {"id":"Q1"}returned 404 whileGET /tasks/activelisted Q1.agent-api.py, appended a throwaway marker-less pending question, confirmedGET /tasks/activelisted it asQ2,POST /answerreturned 200, verified the answered status was written, then restored the pending file and removed the temporary answer task.Reviewer notes
src/agent-api.py; it does not touch the separate front-end transcript persistence issue from the earlier web-chat diagnosis.workspace/hosts/Ruis-MacBook-Pro-2/pending-questions.mdafterward.Dependency: must be followed by #1928 (
fix(agent-api): serialize voice_desired_state mutations under a lock) — that PR closes the race that switching toThreadingHTTPServerintroduces.