os-backlog: wakeup-polling + tmux-convention design spike (issue #28, ADR-0035/0036)

Poll + tmux direction chosen at the human gate. Thin pilot: Backlog::Wakeup
+ bin/wakeup-poll (dry-run default; opt-in wakeup=true; afk-ready acts,
semi triage-only, else pull-only notify). No cron installed. Suite 128/0.
Follow-up: single designated poller machine; cadence deferred to first opt-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CLeMz48rvG3s9XpAsDxeho
This commit is contained in:
jared 2026-07-13 11:02:44 -04:00
parent 3708c0099d
commit 43932d40ab
9 changed files with 400 additions and 4 deletions

View File

@ -0,0 +1,27 @@
---
id: "0035"
date: 2026-07-13
status: Accepted
supersedes:
superseded-by:
affected-paths: [plugins/os-backlog/bin/wakeup-poll, plugins/os-backlog/lib/backlog/wakeup.rb]
affected-components: [os-backlog]
---
# 0035 — Issue-triggered project AI wakeup via polling, not webhooks
## Context
Issue #28 (design spike, blocked-by #27/ADR-0034): when an issue is filed with a project — often via cross-project file-don't-fix — that project's AI should be awakened to triage or act, instead of waiting for the human to open a session. The spike must pick a trigger mechanism, autonomy guardrails, credential scope, and context handoff. The human pre-chose the direction: polling trigger + tmux execution surface (ADR-0036 records the tmux convention).
## Decision
Trigger is a poller, not webhooks: a cron/heartbeat script (pilot: `plugins/os-backlog/bin/wakeup-poll`, one pass per invocation, no cron installed yet) scans opted-in projects for NEW open issues. (1) Scope: rows of the global project index `~/.cc-os/projects.json` (ADR-0034) whose tracker is `forgejo:` or `github:`, AND whose per-project `.cc-os/config` carries the explicit opt-in marker `wakeup=true` (the index row path locates the config; a later config-write slice may mirror the flag into the row — the config file remains the authority). Planka/repo-tracked projects are never polled. (2) New-ness: last-seen state persisted at `~/.cc-os/wakeup-state.json` (per-repo highest issue index seen); first scan of a repo baselines without waking anything. (3) Autonomy, in ADR-0029 vocabulary via issue labels: `afk-ready` -> spawn an acting session; `semi` -> spawn a triage-only session (label + comment, stop at proposing — never implement); anything else (hitl or unlabeled) -> notify-only. Notification policy v2 (pull-only) applies: the scan appends to a summary the human pulls (`~/.cc-os/wakeup-summary.md`) — no push notifications. (4) Credentials: the poller lists issues through tea's existing login only; it NEVER reads `~/.credentials` contents; spawned sessions get Planka creds the established way (source in a subshell). (5) Context handoff: the spawned session's initial prompt carries the issue number, title, URL, and the Discoverer block if present (ADR-0034), plus its autonomy tier. (6) Execution surface is a tmux session per ADR-0036.
## Consequences
Easier: host-agnostic (works for Forgejo and GitHub identically through tea/gh), no listener endpoint, no inbound network surface, trivially disabled (remove the cron line or the wakeup flag). Harder: latency is the poll interval, and the poller is per-machine state — two machines polling would double-wake (accepted for the single-operator pilot; state file is per-host). Non-goals, explicitly: no webhooks; no auto-merge/auto-close; no acting on hitl/unlabeled issues; wakened sessions still obey file-don't-fix (ADR-0034) — they never edit other projects; no bulk backfill of pre-existing open issues (baseline-then-watch).
## Alternatives rejected
1) Forgejo webhooks -> local listener — rejected for the first cut: needs an always-up endpoint reachable from the Forgejo host, doesn't cover GitHub without a second mechanism, and adds an inbound attack surface; polling via tea reuses existing credentials and works everywhere. Webhooks remain the natural upgrade if poll latency ever matters. 2) Opt-in as a field only in the global index row — rejected: the index is derived and rebuildable (ADR-0034); a rebuild would silently drop opt-ins. Authority lives in the per-project .cc-os/config (ADR-0027). 3) Push notifications per new issue (Apprise, ADR-0024) — rejected here: notification policy v2 is pull-only for ambient status; push stays reserved for the human explicitly asking.

View File

@ -0,0 +1,27 @@
---
id: "0036"
date: 2026-07-13
status: Accepted
supersedes:
superseded-by:
affected-paths: [plugins/os-backlog/bin/wakeup-poll, plugins/os-backlog/lib/backlog/wakeup.rb]
affected-components: [os-backlog]
---
# 0036 — tmux session convention for AI-run sessions: cc-<project>-<purpose>
## Context
Issue #28 chose tmux (not headless claude -p / Agent SDK) as the execution surface for issue-triggered wakeup (ADR-0035), because the human must be able to discover, inspect, and take over an AI-run session. The naming/lifecycle convention is useful beyond wakeup — any long-running or unattended AI session benefits — so it is recorded as its own ADR. No existing tmux naming habit to preserve (no tmux server was running at spike time).
## Decision
(1) Naming: AI-run sessions are named `cc-<project>-<purpose>[-<n>]``cc-` prefix marks Claude-operated sessions, `<project>` is the index-row project name (ADR-0034), `<purpose>` is short kebab-case (wakeup sessions use `issue<N>`, e.g. `cc-llf-schema-issue12`), `-<n>` only disambiguates collisions. Human personal sessions never take the `cc-` prefix. (2) Creation: whoever needs the session creates it — the wakeup poller (ADR-0035) for issue wakeups, a human for anything else — via `tmux new-session -d -s <name> -c <project-root> ...` so the pane starts in the project. (3) Lifecycle: a session is killed after clean completion once its closing artifact exists (for wakeups: the triage/summary comment posted on the issue); it is kept alive on error or whenever hitl input is needed, so the evidence and the takeover point are the same pane. Discovery is `tmux ls` — the `cc-` prefix makes AI sessions self-listing; an os-backlog-adjacent listing command is an explicit non-goal for now. (4) Attach/takeover: attaching pauses nothing — the pane runs an interactive Claude Code session, so a human who attaches can simply type into it; there is no separate handover protocol.
## Consequences
Easier: one glanceable namespace (`tmux ls | grep ^cc-`) for everything the AI is running on a machine; takeover is native tmux attach, no tooling. Harder: kill-on-clean-completion means a successful session leaves no scrollback — the issue comment is the record, so sessions must actually post it before exiting (the wakeup prompt makes this the final required step); session names carry no timestamp, so a stale kept-alive session must be triaged by attaching, not by name.
## Alternatives rejected
1) Headless execution (claude -p / Agent SDK) — rejected by the human for this stage: no inspection or takeover surface; may return later for pure afk-ready work once trust is established. 2) Timestamped session names (cc-<project>-20260713-...) — rejected: names become unreadable in tmux ls and collisions are rare enough for -<n>. 3) Keep every completed session for audit — rejected: the durable record belongs on the issue/card (ADR-0033 spirit), and dead sessions accumulate into noise.

View File

@ -40,5 +40,7 @@ One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
| 0031 | [os-context prompt-composer plugin supersedes single-file os-orchestration](0031-os-context-prompt-composer-plugin-supersedes-single-file-os-orchestration.md) | Accepted | 2026-07-13 | | 0031 | [os-context prompt-composer plugin supersedes single-file os-orchestration](0031-os-context-prompt-composer-plugin-supersedes-single-file-os-orchestration.md) | Accepted | 2026-07-13 |
| 0032 | [AI-maintained knowledge files are indexes with progressive disclosure](0032-ai-maintained-knowledge-files-are-indexes-with-progressive-disclosure.md) | Accepted | 2026-07-13 | | 0032 | [AI-maintained knowledge files are indexes with progressive disclosure](0032-ai-maintained-knowledge-files-are-indexes-with-progressive-disclosure.md) | Accepted | 2026-07-13 |
| 0033 | [Tracker routing: Planka is state, git issues are specs](0033-tracker-routing-planka-is-state-git-issues-are-specs.md) | Accepted | 2026-07-13 | | 0033 | [Tracker routing: Planka is state, git issues are specs](0033-tracker-routing-planka-is-state-git-issues-are-specs.md) | Accepted | 2026-07-13 |
| 0034 | [Cross-project filing: file-dont-fix with a derived global project index](0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md) | Proposed | 2026-07-13 | | 0034 | [Cross-project filing: file-dont-fix with a derived global project index](0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md) | Accepted | 2026-07-13 |
| 0035 | [Issue-triggered project AI wakeup via polling, not webhooks](0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md) | Accepted | 2026-07-13 |
| 0036 | [tmux session convention for AI-run sessions: cc-<project>-<purpose>](0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md) | Accepted | 2026-07-13 |
<!-- adr-index:end --> <!-- adr-index:end -->

View File

@ -65,7 +65,8 @@ entry is in the leaf file named.
cache via BoardResolver (issue #22); slice 8 — Operations board + tracker-routing rubric cache via BoardResolver (issue #22); slice 8 — Operations board + tracker-routing rubric
category + ADR-0033 canonizing Planka-state/git-issues-spec (issue #17); route inspect category + ADR-0033 canonizing Planka-state/git-issues-spec (issue #17); route inspect
issue-shape classification + split-by-kind default (issue #26); cross-project filing issue-shape classification + split-by-kind default (issue #26); cross-project filing
convention + derived global project index (issue #27, ADR-0034). Detail: convention + derived global project index (issue #27, ADR-0034); wakeup-polling +
tmux-convention spike with dry-run pilot (issue #28, ADR-0035/0036). Detail:
os-context / os-doc-hygiene / os-backlog leaves. os-context / os-doc-hygiene / os-backlog leaves.
**Remaining optional items:** additional project onboarding (one at a time, per ADR-0013); **Remaining optional items:** additional project onboarding (one at a time, per ADR-0013);

View File

@ -116,5 +116,13 @@ column ownership).
`skills/route/references/cross-project-filing.md`; one CROSS-PROJECT rule line added to `skills/route/references/cross-project-filing.md`; one CROSS-PROJECT rule line added to
the SessionStart note. 9 new tests; suite 112 runs / 233 assertions / 0 failures. Live the SessionStart note. 9 new tests; suite 112 runs / 233 assertions / 0 failures. Live
smoke: cc-os row present in the real index. smoke: cc-os row present in the real index.
- **Outstanding:** #14 residual (onboard one more project — operational hitl); #28 wakeup - **Wakeup design spike (2026-07-13, issue #28, ADR-0035/0036):** poll + tmux direction
design spike (poll + tmux direction chosen 2026-07-13). chosen at the human gate. `Backlog::Wakeup` + `bin/wakeup-poll` thin pilot (dry-run
default, `--spawn` to create `cc-<project>-issue<N>` tmux sessions and persist
`~/.cc-os/wakeup-state.json`; opt-in `wakeup=true` in `.cc-os/config`; afk-ready → act,
semi → triage-only, else notify-only into pull-only `~/.cc-os/wakeup-summary.md`). No
cron installed; 6 tests; suite 128 runs / 0 failures. Follow-up recorded: single
designated poller machine (state stays per-host); cadence/cron mechanism and a
`config-write` wakeup key deferred until a first project opts in.
- **Outstanding:** #14 residual (onboard one more project — operational hitl); wakeup
rollout (first opt-in project + poller machine + cadence).

View File

@ -0,0 +1,131 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# wakeup-poll: one pass of the issue-triggered wakeup poller (ADR-0035).
#
# Reads the global project index (~/.cc-os/projects.json, ADR-0034), and for
# each wakeup-opted-in forgejo:/github: project lists open issues (tea/gh),
# diffs against last-seen state (~/.cc-os/wakeup-state.json), and for each
# NEW issue either spawns a tmux session (ADR-0036) or — the default —
# prints the command it WOULD run. hitl/unlabeled issues are notify-only:
# appended to ~/.cc-os/wakeup-summary.md (pull-only, notification policy v2).
#
# Usage:
# wakeup-poll # dry-run: print planned actions, touch no state
# wakeup-poll --spawn # actually create tmux sessions + persist state
#
# Fails soft everywhere: missing index, unroutable projects, tea/gh errors,
# and absent tmux each degrade to a printed note, never a crash. The poller
# uses tea/gh's existing logins only and never reads ~/.credentials.
# Pilot only — install no cron around this until ADR-0035 is Accepted.
require "json"
require "open3"
require "shellwords"
require_relative "../lib/backlog"
spawn_mode = ARGV.include?("--spawn")
def note(msg) = puts("wakeup-poll: #{msg}")
def list_issues(kind, slug)
cmd =
case kind
when "forgejo" then ["tea", "issues", "list", "--repo", slug, "--state", "open", "--output", "json"]
when "github" then ["gh", "issue", "list", "--repo", slug, "--state", "open",
"--json", "number,title,labels,body,url"]
end
out, err, status = Open3.capture3(*cmd)
return nil unless status.success?
rows = JSON.parse(out)
# Normalize gh's shape to tea's {index, title, labels}.
rows.map do |r|
{ "index" => (r["index"] || r["number"]).to_s,
"title" => r["title"],
"labels" => r["labels"].is_a?(Array) ? r["labels"].map { |l| l["name"] }.join(",") : r["labels"].to_s,
"body" => r["body"].to_s,
"url" => r["url"].to_s }
end
rescue StandardError => e
warn "wakeup-poll: #{slug}: #{e.class}: #{e.message}"
nil
end
index = Backlog::ProjectIndex.new
projects = index.all
if projects.empty?
note "project index empty or unreadable — nothing to do"
exit 0
end
config_reader = lambda do |path|
file = File.join(path, ".cc-os", "config")
File.exist?(file) ? File.read(file) : ""
rescue StandardError
""
end
eligible = Backlog::Wakeup.eligible_projects(projects, config_reader: config_reader)
skipped = projects.keys - eligible.map { |p| p[:path] }
skipped.each { |path| note "skip #{path} (#{projects[path]['tracker'] || 'no tracker'}: not a wakeup-eligible forgejo/github project, or wakeup=true not set)" }
if eligible.empty?
note "no opted-in forgejo/github projects — nothing to do"
exit 0
end
state = Backlog::Wakeup::State.new
summary_path = File.join(File.dirname(Backlog::Wakeup::State.default_path), "wakeup-summary.md")
eligible.each do |project|
issues = list_issues(project[:tracker_kind], project[:repo_slug])
if issues.nil?
note "#{project[:repo_slug]}: issue listing failed (is #{project[:tracker_kind] == 'forgejo' ? 'tea' : 'gh'} logged in?) — skipping"
next
end
last_seen = state.last_seen(project[:repo_slug])
if last_seen.nil?
top = Backlog::Wakeup.highest_index(issues)
note "#{project[:repo_slug]}: first scan — baselining at ##{top}, waking nothing"
state.record(project[:repo_slug], top) if spawn_mode
next
end
fresh = Backlog::Wakeup.new_issues(issues, last_seen)
note "#{project[:repo_slug]}: #{fresh.size} new issue(s) since ##{last_seen}" if fresh.any?
fresh.each do |issue|
tier = Backlog::Wakeup.tier(issue["labels"])
if tier == :notify
line = "- #{Time.now.strftime('%Y-%m-%d')} #{project[:repo_slug]}##{issue['index']} (#{tier}): #{issue['title']}"
if spawn_mode
File.open(summary_path, "a") { |f| f.puts(line) }
note "notify-only -> appended to #{summary_path}"
else
note "would append to #{summary_path}: #{line}"
end
next
end
prompt = Backlog::Wakeup.session_prompt(
issue: issue, tier: tier,
url: issue["url"].empty? ? "#{project[:tracker_kind]}:#{project[:repo_slug]}##{issue['index']}" : issue["url"]
)
cmd = Backlog::Wakeup.tmux_command(
project: project[:name], path: project[:path],
issue_index: issue["index"], prompt: prompt
)
if spawn_mode
ok = system(*cmd)
note ok ? "spawned #{cmd[4]}" : "tmux spawn failed for #{cmd[4]} (is tmux installed?)"
else
note "would run: #{cmd.shelljoin}"
end
end
state.record(project[:repo_slug], Backlog::Wakeup.highest_index(issues, last_seen)) if spawn_mode
end
note "dry-run complete — no state written, no sessions spawned (use --spawn)" unless spawn_mode

View File

@ -8,3 +8,4 @@ require_relative "backlog/project_index"
require_relative "backlog/resolver" require_relative "backlog/resolver"
require_relative "backlog/tracker" require_relative "backlog/tracker"
require_relative "backlog/triage_check" require_relative "backlog/triage_check"
require_relative "backlog/wakeup"

View File

@ -0,0 +1,138 @@
require "json"
require "fileutils"
module Backlog
# Pure logic for the issue-triggered wakeup poller (ADR-0035): which
# projects are polled, which issues count as new, what autonomy tier a
# label set maps to, and the tmux command a wakeup would run (ADR-0036).
#
# All methods are pure over injected data — the bin/wakeup-poll glue does
# the shelling out (tea/gh, tmux) and file IO. Read paths never raise.
module Wakeup
module_function
# Autonomy tiers, from ADR-0029 label vocabulary (ADR-0035 mapping):
# afk-ready -> acting session; semi -> triage-only session;
# anything else (hitl / unlabeled) -> notify-only.
def tier(labels)
names = Array(labels).flat_map { |l| l.to_s.split(",") }.map(&:strip)
return :afk_ready if names.include?("afk-ready")
return :semi if names.include?("semi")
:notify
end
# Filter global-index rows (ADR-0034 shape: path -> row) down to
# wakeup-eligible projects: tracker forgejo:/github: AND the per-project
# .cc-os/config carries wakeup=true (config is the authority; the
# config_reader is injected so this stays pure).
#
# @return [Array<Hash>] rows as {path:, name:, tracker_kind:, repo_slug:}
def eligible_projects(projects, config_reader:)
projects.filter_map do |path, row|
tracker = row["tracker"].to_s
kind, slug = tracker.split(":", 2)
next unless %w[forgejo github].include?(kind) && !slug.to_s.empty?
next unless opted_in?(config_reader.call(path))
{ path: path, name: row["name"] || File.basename(path),
tracker_kind: kind, repo_slug: slug }
end
end
def opted_in?(config_text)
config_text.to_s.each_line.any? { |l| l.strip == "wakeup=true" }
end
# Issues newer than the last-seen index for this repo. A nil last_seen
# means the repo has never been scanned: baseline without waking
# anything (ADR-0035 — no bulk backfill of pre-existing issues).
def new_issues(issues, last_seen)
return [] if last_seen.nil?
issues.select { |i| i["index"].to_i > last_seen.to_i }
end
def highest_index(issues, floor = 0)
([floor.to_i] + issues.map { |i| i["index"].to_i }).max
end
# The tmux command a wakeup runs (ADR-0036 naming: cc-<project>-issue<N>).
def tmux_command(project:, path:, issue_index:, prompt:)
[
"tmux", "new-session", "-d",
"-s", "cc-#{project}-issue#{issue_index}",
"-c", path,
"claude", prompt
]
end
# Initial prompt for the wakened session: issue coordinates + autonomy
# tier + the standing guardrails (ADR-0035 context handoff). The issue
# body (with any Discoverer block) is passed through verbatim.
def session_prompt(issue:, tier:, url:)
action = tier == :afk_ready ? AFK_READY_ACTION : SEMI_ACTION
<<~PROMPT
Wakeup: new issue ##{issue['index']} — #{issue['title']}
#{url}
#{action}
File-don't-fix still applies (ADR-0034): never edit other projects.
Before exiting, post your triage/summary as a comment on the issue
that comment is the durable record (ADR-0036 lifecycle).
Issue body:
#{issue['body']}
PROMPT
end
AFK_READY_ACTION =
"This issue is labeled afk-ready: triage it and, if it is actionable " \
"within this repo, do the work."
SEMI_ACTION =
"This issue is labeled semi: TRIAGE ONLY — label, comment, and " \
"propose an approach, but stop at proposing. Do not implement."
# Last-seen state (~/.cc-os/wakeup-state.json): repo_slug -> highest
# issue index seen. Missing/corrupt reads as empty; writes are atomic.
class State
def self.default_path(env: ENV)
base = env["CC_OS_HOME"] || File.join(env.fetch("HOME", Dir.home), ".cc-os")
File.join(base, "wakeup-state.json")
end
def initialize(path: self.class.default_path)
@path = path
end
def last_seen(repo_slug)
read[repo_slug]
end
def record(repo_slug, index)
data = read
data[repo_slug] = index
write(data)
end
private
def read
return {} unless File.exist?(@path)
parsed = JSON.parse(File.read(@path))
parsed.is_a?(Hash) ? parsed : {}
rescue StandardError
{}
end
def write(data)
dir = File.dirname(@path)
FileUtils.mkdir_p(dir)
temp = File.join(dir, ".wakeup-state.json.tmp.#{Process.pid}")
File.write(temp, JSON.pretty_generate(data) + "\n")
File.rename(temp, @path)
end
end
end
end

View File

@ -0,0 +1,61 @@
require_relative "test_helper"
require "tmpdir"
require "json"
class WakeupTest < Minitest::Test
W = Backlog::Wakeup
def test_tier_mapping
assert_equal :afk_ready, W.tier("afk-ready")
assert_equal :afk_ready, W.tier("bug, afk-ready")
assert_equal :semi, W.tier("semi")
assert_equal :notify, W.tier("hitl")
assert_equal :notify, W.tier("")
assert_equal :notify, W.tier(nil)
end
def test_eligible_projects_requires_git_tracker_and_optin
projects = {
"/p/planka" => { "name" => "planka-proj", "tracker" => "planka:board" },
"/p/forgejo-in" => { "name" => "fin", "tracker" => "forgejo:jared/fin" },
"/p/forgejo-out" => { "name" => "fout", "tracker" => "forgejo:jared/fout" },
"/p/github-in" => { "name" => "gin", "tracker" => "github:jared/gin" }
}
reader = ->(path) { path.end_with?("-in") ? "tracker=x\nwakeup=true\n" : "tracker=x\n" }
rows = W.eligible_projects(projects, config_reader: reader)
assert_equal %w[/p/forgejo-in /p/github-in], rows.map { |r| r[:path] }
assert_equal "jared/fin", rows.first[:repo_slug]
assert_equal "forgejo", rows.first[:tracker_kind]
end
def test_new_issues_baselines_on_nil_last_seen
issues = [{ "index" => "5" }, { "index" => "3" }]
assert_equal [], W.new_issues(issues, nil)
assert_equal [{ "index" => "5" }], W.new_issues(issues, 3)
assert_equal 5, W.highest_index(issues)
assert_equal 9, W.highest_index(issues, 9)
end
def test_tmux_command_follows_adr_0036_naming
cmd = W.tmux_command(project: "llf-schema", path: "/p/llf", issue_index: "12", prompt: "hi")
assert_equal %w[tmux new-session -d -s cc-llf-schema-issue12 -c /p/llf claude hi], cmd
end
def test_session_prompt_carries_tier_and_body
issue = { "index" => "7", "title" => "T", "body" => "Body\n**Discoverer:** x" }
semi = W.session_prompt(issue: issue, tier: :semi, url: "u")
assert_includes semi, "TRIAGE ONLY"
assert_includes semi, "**Discoverer:** x"
afk = W.session_prompt(issue: issue, tier: :afk_ready, url: "u")
assert_includes afk, "afk-ready"
end
def test_state_reads_missing_as_empty_and_records_atomically
Dir.mktmpdir do |dir|
state = W::State.new(path: File.join(dir, "wakeup-state.json"))
assert_nil state.last_seen("jared/x")
state.record("jared/x", 7)
assert_equal 7, W::State.new(path: File.join(dir, "wakeup-state.json")).last_seen("jared/x")
end
end
end