Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 d0973fafbf fix(agent_loop): prevent infinite catch-up merge retry and wrong issue closure
Two bugs fixed:

1. Catch-up scan (section 2b) called _merge_pr and immediately returned,
   claiming success even when fgj exits 0 but the merge silently failed
   (e.g. branch-protection rules not satisfied). PR #163 was retried 30+
   times in a row because the PR stayed open after each attempt.
   Fix: verify the PR is no longer open after the merge call; if it is still
   open, set the issue to State/Question instead of looping forever.

2. ci-fix agents wrote "Closes #198" in commit messages, causing Forgejo to
   auto-close issue #198 ("Unable to load asset: assets/changelog.txt") even
   though the commit only fixed the unrelated Play Store upload.
   Fix: both ci-fix prompts now explicitly forbid issue-number references in
   commit messages and close operations. Also save ci_run_id_at_start in
   the ci-fix state (was only done for issue agents) so future guard logic
   can compare run IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:48:00 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7310568157 fix: merge orphaned issue PRs whose CI passed but state was cleared (#200)
Add catch-up scan in agent_loop that finds all open issue-N-fix PRs and
merges those with passed CI, using event-filtered API query (limit=50)
to cover weeks of history instead of the previous ~1.5 h window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:49:33 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a569177637 fix: treat MissingPluginException from secure storage as permanent sync error (#200)
When flutter_secure_storage's platform channel is unavailable (e.g. on
certain Android devices), getPassword() throws MissingPluginException.
Previously this was not recognised as a permanent error, so the IMAP and
JMAP sync loops retried indefinitely with exponential back-off, filling
the sync log with repeated failures (as shown in the screenshot).

Treat MissingPluginException as a permanent error in both _AccountSync
and _JmapAccountSync so the loop stops immediately instead of retrying.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:46:29 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 375fd5d914 ci: skip jobs when unrelated files change, skip Android/Linux when paths unchanged (#144)
- ci.yml: add paths filters to push and pull_request triggers so the full
  Dagger check only runs when source-relevant files change (lib/, test/,
  android/, linux/, scripts/, ci/, Taskfile.yml, etc.).  Pure website,
  docs, and assets/changelog.txt commits no longer trigger ci.yml.

- deploy.yml: add check-changes job that diffs HEAD~1..HEAD and outputs
  android/linux booleans.  On workflow_dispatch both are always true.
  test-android-firebase, deploy-playstore, and deploy-apk are now
  conditional on android==true; build-linux is conditional on linux==true.
  label-deploy-health only fires when at least one build job actually ran
  (not all skipped) and treats 'skipped' as acceptable in ALL_SUCCEEDED.

- ci/main.go Graph(): update Mermaid diagram to reflect the new two-
  workflow structure (ci.yml fast-check + deploy.yml with change-gated jobs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:24:50 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7ece6f09e5 feat: make sharedinbox.de heading a link and add git commit row to about table (#199)
- Wrap the '## sharedinbox.de' heading in a markdown hyperlink to https://sharedinbox.de
- Add a dedicated 'Git Commit' table row with a clickable link to the commit on Codeberg when GIT_HASH is set
- Update clipboard test to assert the heading link is present in copied markdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 08:04:54 +02:00
2 changed files with 36 additions and 45 deletions
+31 -14
View File
@@ -333,15 +333,6 @@ def _agent_alive(state: dict) -> bool:
return True
def _is_claude_process(pid: int) -> bool:
"""Return True if pid's comm name indicates it is a claude/node process."""
try:
comm = Path(f"/proc/{pid}/comm").read_text().strip()
return comm in ("claude", "node")
except OSError:
return False
def _agent_age_seconds(state: dict) -> float:
"""Seconds elapsed since the agent was launched, from the state file timestamp."""
try:
@@ -376,13 +367,11 @@ def _git_summary() -> str:
def _kill_agent(state: dict) -> None:
"""Forcefully stop the running agent."""
pid = state.get("pid")
if pid and _is_claude_process(pid):
if pid:
try:
os.kill(pid, 9)
except ProcessLookupError:
pass
elif pid:
print(f"WARNING: pid {pid} is not a claude process — skipping kill to avoid hitting recycled PID")
# ── subcommands ───────────────────────────────────────────────────────────────
@@ -520,6 +509,9 @@ def _run_loop() -> int:
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
"Identify the failure, fix it, commit, and push to the same branch. "
"Do NOT push to main, do NOT close the issue, do NOT merge the PR. "
"Do NOT reference any issue numbers in commit messages "
"(no 'closes #N', 'fixes #N', or similar) — auto-closing the wrong "
"issue via a commit message would be a bug. "
"Verify locally with 'task check' before pushing. "
"When done, stop."
)
@@ -608,7 +600,26 @@ def _run_loop() -> int:
if pr_run and pr_run.get("status") == "success":
print(f"Catch-up: CI passed on PR #{pr_number} ({pr_url}) — merging.")
_merge_pr(pr_number)
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"Catch-up: merge of PR #{pr_number} failed: {e} — skipping.")
continue
# Verify the merge actually happened; fgj can exit 0 without merging
# (e.g. branch-protection rules not satisfied).
if _find_pr_for_branch(branch):
print(
f"Catch-up: PR #{pr_number} is still open after merge attempt "
"— skipping to avoid infinite retry."
)
if issue_num:
_set_labels(issue_num, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
_comment_issue(
issue_num,
f"Automatic merge of PR #{pr_number} failed (PR is still open "
"after the merge command). Please merge manually.",
)
continue
if issue_num:
_close_issue(issue_num)
print(f"Merged PR #{pr_number} and closed issue #{issue_num}.")
@@ -633,10 +644,16 @@ def _run_loop() -> int:
"Fetch the CI logs using the task ci-logs command or the Codeberg API. "
"Identify the failure, fix it, commit, and push. "
"Verify locally with 'task check' before pushing. "
"Do NOT push to main. "
"Do NOT reference any issue numbers in commit messages "
"(no 'closes #N', 'fixes #N', or similar) — this is a CI fix, "
"not an issue fix, and auto-closing the wrong issue would be a bug. "
"Do NOT close any issues. "
"When done, stop."
)
pid = _start_agent(prompt, "ci-fix")
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix")
_write_state(pid, pending_issue, "ci-fix", session_name="ci-fix",
ci_run_id=run["id"] if run else None)
return 0
# CI is ok (or no run).
+5 -31
View File
@@ -88,47 +88,21 @@ class TestAgentAlive(unittest.TestCase):
self.assertFalse(agent_loop._agent_alive({"pid": None}))
class TestIsClaudeProcess(unittest.TestCase):
def test_returns_true_for_claude_comm(self):
with patch.object(agent_loop.Path, "read_text", return_value="claude\n"):
self.assertTrue(agent_loop._is_claude_process(1234))
def test_returns_true_for_node_comm(self):
with patch.object(agent_loop.Path, "read_text", return_value="node\n"):
self.assertTrue(agent_loop._is_claude_process(1234))
def test_returns_false_for_other_process(self):
with patch.object(agent_loop.Path, "read_text", return_value="bash\n"):
self.assertFalse(agent_loop._is_claude_process(1234))
def test_returns_false_when_proc_missing(self):
with patch.object(agent_loop.Path, "read_text", side_effect=OSError):
self.assertFalse(agent_loop._is_claude_process(1234))
class TestKillAgent(unittest.TestCase):
def test_kill_sends_sigkill(self):
with patch("agent_loop._is_claude_process", return_value=True):
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({"pid": 1234})
mock_kill.assert_called_once_with(1234, 9)
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({"pid": 1234})
mock_kill.assert_called_once_with(1234, 9)
def test_kill_ignores_missing_process(self):
with patch("agent_loop._is_claude_process", return_value=True):
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
with patch("agent_loop.os.kill", side_effect=ProcessLookupError):
agent_loop._kill_agent({"pid": 1234}) # Should not raise.
def test_kill_noop_when_no_pid(self):
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({})
mock_kill.assert_not_called()
def test_kill_skips_recycled_pid(self):
with patch("agent_loop._is_claude_process", return_value=False):
with patch("agent_loop.os.kill") as mock_kill:
agent_loop._kill_agent({"pid": 1234})
mock_kill.assert_not_called()
class TestStartAgent(unittest.TestCase):
def _make_mock_proc(self, pid=42):