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.
This commit is contained in:
oc
2026-08-12 14:29:24 -07:00
parent 712d9c0dff
commit 725d17d3eb
2 changed files with 129 additions and 21 deletions
+20
View File
@@ -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`, `{labels}` `{url}`. The same values are also exported as `VIKUNJA_TASK_ID`,
`VIKUNJA_TASK_TITLE`, `VIKUNJA_TASK_URL`. `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 ### Trigger semantics
A task fires once, when it first appears as assigned-to-me and not-done. A task fires once, when it first appears as assigned-to-me and not-done.
+109 -21
View File
@@ -233,13 +233,25 @@ def _state_path(profile):
def _load_seen(path): 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: try:
with open(path) as f: with open(path) as f:
seen = json.load(f) state = json.load(f)
except (FileNotFoundError, ValueError): except (FileNotFoundError, ValueError):
return {} return {"tasks": {}, "comments": {}}
if "tasks" not in state:
state = {"tasks": state, "comments": {}}
state.setdefault("comments", {})
cutoff = time.time() - SEEN_TTL 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): def _save_seen(path, seen):
@@ -265,6 +277,81 @@ def _substitute(template, task, vk):
return out_str 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): def cmd_watch(vk, args):
"""Poll this bot's assigned queue and run a command for each new task. """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}`") "`vk watch -- nullclaw --task {task_id}`")
state_file = args.state or _state_path(args.profile or me) 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 = 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)})") f"({'dry run' if args.dry_run else ' '.join(args.command)})")
backoff = args.interval backoff = args.interval
@@ -317,29 +406,25 @@ def cmd_watch(vk, args):
# Record before running, not after: at-most-once. A harness that # Record before running, not after: at-most-once. A harness that
# crashes the machine should not be relaunched on every poll. # crashes the machine should not be relaunched on every poll.
seen[key] = time.time() seen[key] = time.time()
_save_seen(state_file, seen) _save_seen(state_file, state)
command = [_substitute(part, task, vk) for part in args.command] command = [_substitute(part, task, vk) for part in args.command]
log(f"task #{task['id']} {task.get('title')!r} -> {' '.join(command)}") log(f"task #{task['id']} {task.get('title')!r} -> {' '.join(command)}")
if args.dry_run: if args.dry_run:
continue continue
env = dict(os.environ) _run_command(args, command, {
env["VIKUNJA_TASK_ID"] = str(task["id"]) "VIKUNJA_TASK_ID": task["id"],
env["VIKUNJA_TASK_TITLE"] = task.get("title") or "" "VIKUNJA_TASK_TITLE": task.get("title") or "",
env["VIKUNJA_TASK_URL"] = f"{vk.url}/tasks/{task['id']}" "VIKUNJA_TASK_URL": f"{vk.url}/tasks/{task['id']}",
if args.profile: "VIKUNJA_TRIGGER": "task",
env["VIKUNJA_PROFILE"] = args.profile }, lambda m: log(f"task #{task['id']} {m}"))
if args.comments:
try: try:
result = subprocess.run(command, cwd=args.cwd, env=env, _poll_comments(vk, args, state, state_file, me, log)
stdin=subprocess.DEVNULL, timeout=args.timeout) except VikunjaError as e:
log(f"task #{task['id']} finished rc={result.returncode}") log(f"comment poll failed: {e}")
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}")
if args.once: if args.once:
return return
@@ -452,6 +537,9 @@ def build_parser():
w.add_argument("--state", help="Override the handled-task state file.") 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("--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("--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, w.add_argument("command", nargs=argparse.REMAINDER,
help="Command to run, after `--`. Placeholders: {task_id} {title} " help="Command to run, after `--`. Placeholders: {task_id} {title} "
"{project_id} {priority} {labels} {url}") "{project_id} {priority} {labels} {url}")