From 725d17d3ebc87b22621154627074b2c702107710 Mon Sep 17 00:00:00 2001 From: oc Date: Wed, 12 Aug 2026 14:29:24 -0700 Subject: [PATCH] watch: fire on new comments, not just new tasks A follow-up question on a task produced no event at all, so an unattended conversation stalled silently. --comments polls the account's assigned tasks (including done ones, where follow-ups land) and fires for comments written by anyone else. Guards: comments by the account itself never fire, so a replying agent cannot wake itself; on first sight of a task the newest comment id is adopted without firing, so enabling this does not replay history; the comment id is recorded before the command runs, so a wedged harness is not relaunched every poll. --- README.md | 20 +++++++++ vk | 130 +++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 129 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index a0cbe6e..16d830e 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,26 @@ Available in the command: `{task_id}` `{title}` `{project_id}` `{priority}` `{labels}` `{url}`. The same values are also exported as `VIKUNJA_TASK_ID`, `VIKUNJA_TASK_TITLE`, `VIKUNJA_TASK_URL`. +### Replying to comments + +Add `--comments` and the watcher also fires when **someone else** comments on one +of your tasks: + +```sh +vk watch --comments --interval 30 -- nullclaw --task {task_id} +``` + +- Scans your assigned tasks **including done ones** — a follow-up question almost + always lands on a task that was just closed. +- Your own comments never fire it, so an agent replying cannot wake itself. +- On first sight of a task the newest comment id is adopted **without firing**, so + turning this on does not replay the whole history. The flip side: a comment + already sitting there when you enable it will not fire either. To pick it up, + delete that task's entry from the `comments` map in the state file. +- The command gets `VIKUNJA_TRIGGER=comment` plus `VIKUNJA_COMMENT`, + `VIKUNJA_COMMENT_AUTHOR`, `VIKUNJA_COMMENT_ID` alongside the usual task vars. + Task triggers set `VIKUNJA_TRIGGER=task`. + ### Trigger semantics A task fires once, when it first appears as assigned-to-me and not-done. diff --git a/vk b/vk index a813b0c..02db787 100755 --- a/vk +++ b/vk @@ -233,13 +233,25 @@ def _state_path(profile): def _load_seen(path): + """State for both triggers. + + Shape: {"tasks": {task_id: ts}, "comments": {task_id: last_comment_id}}. + Older files were a bare {task_id: ts} map; those are migrated in place so an + upgrade does not re-fire every task already handled. + """ try: with open(path) as f: - seen = json.load(f) + state = json.load(f) except (FileNotFoundError, ValueError): - return {} + return {"tasks": {}, "comments": {}} + + if "tasks" not in state: + state = {"tasks": state, "comments": {}} + state.setdefault("comments", {}) + cutoff = time.time() - SEEN_TTL - return {k: v for k, v in seen.items() if v > cutoff} + state["tasks"] = {k: v for k, v in state["tasks"].items() if v > cutoff} + return state def _save_seen(path, seen): @@ -265,6 +277,81 @@ def _substitute(template, task, vk): return out_str +def _run_command(args, command, env_extra, log): + """Run the configured command once. Never raises.""" + env = dict(os.environ) + env.update({k: str(v) for k, v in env_extra.items() if v is not None}) + if args.profile: + env["VIKUNJA_PROFILE"] = args.profile + try: + result = subprocess.run(command, cwd=args.cwd, env=env, + stdin=subprocess.DEVNULL, timeout=args.timeout) + log(f"finished rc={result.returncode}") + except subprocess.TimeoutExpired: + log(f"timed out after {args.timeout}s") + except FileNotFoundError: + log(f"FAILED: no such command: {command[0]}") + except Exception as e: + log(f"FAILED: {e!r}") + + +def _poll_comments(vk, args, state, state_file, me, log): + """Fire for comments written by someone other than this account. + + Scans the account's assigned tasks *including done ones* -- a follow-up + question almost always lands on a task that was just closed. On first sight + of a task the newest comment id is recorded without firing, so enabling this + on an existing board does not replay months of history. + """ + filters = [f"assignees in '{me}'"] + list(args.filter or []) + tasks = vk.tasks(project_id=args.project, filter=" && ".join(filters), sort_by="id") + + for task in tasks: + key = str(task["id"]) + try: + comments = vk.comments(task["id"]) + except VikunjaError as e: + log(f"comment poll failed for #{task['id']}: {e}") + continue + if not comments: + continue + + newest = max(int(c["id"]) for c in comments) + if key not in state["comments"]: + # First time we have looked at this task: adopt, do not replay. + state["comments"][key] = newest + _save_seen(state_file, state) + continue + + last = int(state["comments"][key]) + fresh = [c for c in comments + if int(c["id"]) > last + and (c.get("author") or {}).get("username") != me] + if not fresh: + state["comments"][key] = max(last, newest) + continue + + state["comments"][key] = newest + _save_seen(state_file, state) # at-most-once, same as task triggers + + for c in fresh: + author = (c.get("author") or {}).get("username", "?") + body = c.get("comment", "") + log(f"comment on #{task['id']} by {author}: {body[:60]!r}") + if args.dry_run: + continue + command = [_substitute(part, task, vk) for part in args.command] + _run_command(args, command, { + "VIKUNJA_TASK_ID": task["id"], + "VIKUNJA_TASK_TITLE": task.get("title") or "", + "VIKUNJA_TASK_URL": f"{vk.url}/tasks/{task['id']}", + "VIKUNJA_TRIGGER": "comment", + "VIKUNJA_COMMENT_ID": c.get("id"), + "VIKUNJA_COMMENT_AUTHOR": author, + "VIKUNJA_COMMENT": body, + }, lambda m: log(f"task #{task['id']} {m}")) + + def cmd_watch(vk, args): """Poll this bot's assigned queue and run a command for each new task. @@ -287,10 +374,12 @@ def cmd_watch(vk, args): "`vk watch -- nullclaw --task {task_id}`") state_file = args.state or _state_path(args.profile or me) - seen = _load_seen(state_file) + state = _load_seen(state_file) + seen = state["tasks"] log = lambda msg: print(f"{datetime.now().isoformat(timespec='seconds')} {msg}", flush=True) - log(f"watching as {me} every {args.interval}s " + triggers = "new tasks" + (" + comments" if args.comments else "") + log(f"watching {triggers} as {me} every {args.interval}s " f"({'dry run' if args.dry_run else ' '.join(args.command)})") backoff = args.interval @@ -317,29 +406,25 @@ def cmd_watch(vk, args): # Record before running, not after: at-most-once. A harness that # crashes the machine should not be relaunched on every poll. seen[key] = time.time() - _save_seen(state_file, seen) + _save_seen(state_file, state) command = [_substitute(part, task, vk) for part in args.command] log(f"task #{task['id']} {task.get('title')!r} -> {' '.join(command)}") if args.dry_run: continue - env = dict(os.environ) - env["VIKUNJA_TASK_ID"] = str(task["id"]) - env["VIKUNJA_TASK_TITLE"] = task.get("title") or "" - env["VIKUNJA_TASK_URL"] = f"{vk.url}/tasks/{task['id']}" - if args.profile: - env["VIKUNJA_PROFILE"] = args.profile + _run_command(args, command, { + "VIKUNJA_TASK_ID": task["id"], + "VIKUNJA_TASK_TITLE": task.get("title") or "", + "VIKUNJA_TASK_URL": f"{vk.url}/tasks/{task['id']}", + "VIKUNJA_TRIGGER": "task", + }, lambda m: log(f"task #{task['id']} {m}")) + + if args.comments: try: - result = subprocess.run(command, cwd=args.cwd, env=env, - stdin=subprocess.DEVNULL, timeout=args.timeout) - log(f"task #{task['id']} finished rc={result.returncode}") - except subprocess.TimeoutExpired: - log(f"task #{task['id']} timed out after {args.timeout}s") - except FileNotFoundError: - log(f"task #{task['id']} FAILED: no such command: {command[0]}") - except Exception as e: - log(f"task #{task['id']} FAILED: {e!r}") + _poll_comments(vk, args, state, state_file, me, log) + except VikunjaError as e: + log(f"comment poll failed: {e}") if args.once: return @@ -452,6 +537,9 @@ def build_parser(): w.add_argument("--state", help="Override the handled-task state file.") w.add_argument("--once", action="store_true", help="One pass then exit (for cron).") w.add_argument("--dry-run", action="store_true", help="Log what would run; run nothing.") + w.add_argument("--comments", action="store_true", + help="Also fire when someone else comments on one of my tasks " + "(includes done tasks -- follow-ups usually land there).") w.add_argument("command", nargs=argparse.REMAINDER, help="Command to run, after `--`. Placeholders: {task_id} {title} " "{project_id} {priority} {labels} {url}")