9gr15dp3db02cts71oga3mcc7a22btti · 485 rows
| id | issue_id | event_type | actor | old_value | new_value | comment | created_at |
|---|---|---|---|---|---|---|---|
| 019fcf30-39f7-7203-8fd5-54cceb56cd8e | ah-wd4 | updated | Eugene Blikh | {"id":"ah-wd4","title":"DECISION: move the task board from Vikunja to beads+Dolt (full swap vs hybrid mirror)","description":"Three independent Opus researchers audited this on 2026-07-20 (board contract / beads capabilities / human workflow). Consensus: writing the adapter is the EASY part; the cost is infrastructure and the human write path.\n\nFEASIBILITY (good news): ports.Board is only 6 methods (Snapshot, MoveToBucket, Comment, Comments, CreateTask, SwapLabel), 22 call sites all in internal/reconcile, and everything else (attempts, runs, lineage, Q\u0026A links) lives in the SQLite store. domain/ is Vikunja-free; bucket names are domain constants. Adapter est. a few hundred lines, minus the 238-line markdown-\u003eHTML converter which a plain-text board does not need. All 9 canonical buckets ARE expressible via beads custom statuses with active/wip/done/frozen categories, wired into the ready_issues view at SQL level. bd ready --claim is a real compare-and-swap (ErrAlreadyClaimed) — stronger than what we have today. Comments live in their own table, NOT mixed with the events audit log, so the ask-user answer detector is safe from machine-generated lines.\n\nBLOCKERS (the real cost):\n1. NO Go library (all packages are internal/), no MCP, no HTTP daemon. The only interfaces are fork/exec of bd --json (~250ms warm) or raw MySQL to a dolt sql-server.\n2. Embedded mode is single-process and BLOCKS UNBOUNDEDLY — measured bd count waiting 43s behind a 40s external DB hold, no timeout knob. Upstream design doc calls multi-process embedded 'unsupported'. Migrating to dolt sql-server mode is MANDATORY (backup + bd init --server + restore; different data dir; a new server process to supervise).\n3. Human write path. Vikunja is the human INPUT surface, not just storage; the viewer is read-only. The killer interaction is ask-user: a card parked in Question (or a live agent polling /api/tool/answer against a 30m timeout) waits on a human comment. Today that is typed from any device in \u003c=20s; under beads it is laptop-only bd comment behind a ~5min auto-push debounce (~17% of a run timeout per exchange). Lowering the interval does not fix the failure CLASS: a local write that reports success and is invisible to the daemon.\n4. Every write is a Dolt commit — a comment per state change plus per-heartbeat progress = write amplification into a version-controlled DAG that auto-pushes. bd batch help names this; bd compact/gc/flatten are the cleanup treadmill.\n5. int64 task IDs are load-bearing (branch task-\u003cid\u003e, zellij session, archive filename, tool-token binding, HTTP API, notification URL). Beads ids are strings (ah-1cx.1). Recommended fix: repo-wide int64-\u003estring (mechanical, compiler-verified, ~10 files) over a synthetic mapping table that can drift.\n6. Snapshot must NEVER be partial: a card missing from a snapshot is treated as vanished and the daemon KILLS the live run and cancels the record. Any adapter must enumerate transactionally or prove completeness (the Vikunja adapter refuses a truncated bucket rather than dropping tasks). Watch bd list default limits.\n7. Same-field concurrent updates are last-writer-wins with no optimistic locking (upstream open question #3); only --claim has CAS. Contradicts the SPEC principle that human intent wins and the daemon aligns.\n8. No change notification, by product charter ('Beads does not need sub-second sync'). Poll the events table by created_at. NOTE: this is NOT a real blocker for us — the Vikunja webhook is explicitly only an acceleration of the 20s poll, and agent-completion latency rides the separate run-exit poke.\n\nWHAT IMPROVES: Task Spec escapes the rich-text editor (plainTextFromHTML exists ONLY to undo Vikunja HTML mangling of YAML frontmatter — becomes deletable); first-class deferred/--defer beats an unmapped Someday column; dependency-aware bd ready for free; agent and human share one tracker beside the code; full history/diff/branching. Attachments are a non-issue (zero code references).\n\nOPTIONS:\nA) HYBRID MIRROR (low risk): keep Vikunja as the board, add a one-way exporter into beads for reporting/milestones. No reconcile changes at all. Note the milestone viewer at dolt.srht.bigb.es/~bigbes/agents-dev/view/milestones already delivers most of this value today.\nB) FULL SWAP, gated on prerequisites: migrate beads to dolt sql-server mode; daemon speaks MySQL directly (or bd --json --sandbox with pushes on its own timer); map ready-\u003estatus open (REQUIRED: the CAS claim hardcodes status='open'); use metadata JSON for daemon-private state; events.created_at as poll cursor; batch comment writes + scheduled compaction; int64-\u003estring ids; AND build a human write path — 3 POST endpoints on the existing httpapi (comment / status / label) reusing the proven bearer-token pattern, plus a small form in the viewer. Because the daemon runs on agent-1 next to the authoritative working set, writes through it have ZERO sync latency and take the laptop out of the write path entirely.\n\nRECOMMENDATION: do not swap while the viewer is read-only. Either stay on Vikunja, or commit to option B including the write path — the write path is what makes it viable, not the adapter. Implementation beads to be filed once this decision is made.","design":"HANDOFF DESIGN — human write path for a beads-backed board. Written 2026-07-20 for a worker with\nno prior conversation context. Read this whole section before touching code.\n\n== WHY THIS EXISTS ==\nVikunja is not merely storage for agenthubd: it is the surface through which a HUMAN expresses\nintent, which the daemon then observes. SPEC.md:36 and :41-43 state the contract — the board is\ndesired state owned by the human, the SQLite store plus runtime is actual state owned by the\ndaemon, and \"the daemon never fights a human drag: human intent wins\". Replacing the board with\nbeads+Dolt while the only web UI is READ-ONLY removes the human's write surface. That is the sole\nremaining blocker to the migration; everything else is tractable work (see the description).\n\n== THE COMPLETE HUMAN INTENT VOCABULARY (do not add verbs beyond these without re-deriving) ==\nEvery human gesture the daemon can observe reduces to four writes. Evidence is by interaction:\n\n1. COMMENT — the only latency-critical write. Two cases:\n a. Card parked in Question: reconcile.go:1151 handleQuestion -\u003e Comments() -\u003e tools.go:263-278\n detectAnswer. It finds the LAST comment containing marker \"agent-hub:awaiting-answer\"\n (reconcile.go:1105); if ANY comment follows it, that trailing comment IS the answer.\n b. Mid-run: tools.go:210-238 ToolAskUser parks the card while the agent stays LIVE polling\n GET /api/tool/answer (tools.go:245-252). The run is burning against cfg.Timeout (default 30m,\n enforced in check()). Delivery latency here is on the critical path of a running agent.\n CONSEQUENCE: any write path slower than ~1 min materially degrades (b). A 5-minute\n auto-push debounce consumes ~17% of a default run budget per exchange.\n\n2. STATUS CHANGE — highest-frequency gesture; five human actions share this one operation:\n trigger work (drag to Ready -\u003e reconcile.go:234-235 handleReady + domain.CanClaim);\n promote an agent-created task out of Triage (created by tools.go:344-350 into\n cfg.AgentTasks.TargetBucket, default triage; Triage is a PARKED bucket, reconcile.go:238-244 —\n never a claim source); cancel/kill (reconcile.go:254-256 handleTerminal:1525-1560 -\u003e runner.Kill,\n outcome killed); route from In Review; park out of the way (unmapped bucket, reconcile.go:228-232).\n\n3. CREATE TASK — title + description + initial status. The description carries the Task Spec YAML\n frontmatter (role/model/skills/timeout), parsed by internal/spec/frontmatter.go:36-58.\n\n4. EDIT DESCRIPTION / LABELS — easy to under-rate. When the daemon REJECTS a Task Spec it bounces\n the card to Triage (reconcile.go:380) with a comment that literally instructs: \"Fix the Task\n Spec in the description, then drag the card back to Ready\" (comments.go:51-59). Without an edit\n path a rejected card is unrecoverable from any device that lacks the bd CLI. Labels are the same\n operation class: the type:\u003cname\u003e label selects the task-type preset (spec.go:194-217) and is read\n by verdict routing (routing.go:23-42). Exactly one type:* label is legal.\n\nNOT needed: assignees, priorities, due dates, attachments, ordering, reactions. The daemon reads\nnone of them (grep -rni attachment internal/ returns zero hits). BoardTask carries only\n{ID, Title, Description, Bucket, Labels, UpdatedAt} and UpdatedAt has zero readers.\n\n== TWO DELIVERY SHAPES — evaluate SHAPE A FIRST, it may be nearly free ==\n\nSHAPE A: one shared Dolt sql-server; no new code.\n Run dolt sql-server on agent-1 beside the daemon; point every bd client at it over the network.\n bd supports this explicitly: 'bd dolt set host \u003cip\u003e [--update-config]', plus port/user/database,\n BEADS_DOLT_SERVER_MODE=1, bd init --server (see bd dolt --help; docs/DOLT.md in the beads source\n says server mode \"connects to a running dolt sql-server for multi-client access ... enables\n concurrent agents\"). With ONE database there is no push, no pull, no debounce, no divergence and\n no merge conflicts. Solves every desk interaction at ~zero engineering cost.\n DOES NOT solve: any device without bd + network access to the server (i.e. phone).\n Costs: a supervised sql-server process; network exposure of the DB port; migration from embedded\n to server mode is backup + 'bd init --server' + restore with a DIFFERENT data dir\n (.beads/dolt/ vs .beads/embeddeddolt/) — not a flag flip.\n\nSHAPE B: HTTP write endpoints on the daemon's existing httpapi.\n POST /api/v1/board/:id/comment {\"text\": \"...\"} -\u003e interaction 1 (DO FIRST)\n POST /api/v1/board/:id/status {\"status\": \"open\"} -\u003e interaction 2 (DO SECOND)\n POST /api/v1/board {\"title\",\"description\",\"status\"} -\u003e interaction 3\n PATCH /api/v1/board/:id {\"description\",\"labels\"} -\u003e interaction 4\n Why the daemon and not the viewer: the daemon runs ON agent-1 next to the authoritative Dolt\n working set, so a write through it has ZERO sync latency — it mutates the DB the reconciler reads\n and pushes on the daemon's own schedule. This takes the laptop out of the write path, which is\n what eliminates the failure CLASS (a local write that reports success and is invisible to the\n daemon). Lowering the auto-push interval only narrows the window; it does not remove the class.\n MINIMUM VIABLE SLICE = comment + status. Those two cover the blocker and the highest-frequency\n gesture. Create/edit can lag because filing new work is a desk activity anyway.\n\n== NON-OBVIOUS COSTS OF SHAPE B (largest hidden cost; read before estimating) ==\n- The daemon is LOOPBACK-ONLY today: config.example.yaml line 1, listen: \"127.0.0.1:9100\".\n A human-facing write API means binding off-loopback, which drags in TLS and a real auth story.\n- Auth machinery to REUSE, not reinvent: internal/reconcile/tools.go:60-137 mints per-task 256-bit\n bearer tokens with a constant-time compare; internal/httpapi/httpapi.go:311-353 does HMAC-SHA256\n verification for the Vikunja webhook. What is genuinely NEW is an OPERATOR token with a different\n lifetime and scope than a per-run token. Do not reuse per-task tokens for humans.\n- TWO WRITE PATHS CAN DIVERGE: if the laptop keeps writing a LOCAL Dolt DB while the HTTP API writes\n agent-1's, the merge problem returns. Shape A avoids this by construction. If shipping B alone,\n point the laptop's bd at agent-1 as well, or consciously accept Dolt merges.\n- Viewer integration: wire the existing read-only viewer's issue rows to POST at these endpoints\n (https://dolt.srht.bigb.es/~bigbes/agents-dev/view/milestones). It already renders id/title/\n priority/type/status and milestone progress; it is a sourcehut-style page with Log in/Register\n in the nav, so an auth context may already exist there.\n\n== INVARIANTS ANY IMPLEMENTATION MUST NOT BREAK ==\n1. NEVER return a partial board from Snapshot. A card missing from a snapshot is treated as VANISHED:\n reconcile.go:1659 handleVanished KILLS the live run and marks the record cancelled. The Vikunja\n adapter refuses a truncated bucket rather than dropping tasks (vikunja/board.go:95-99). See the\n open spike on SearchIssues/IssueFilter default limits — this is the gating unknown.\n2. Do NOT let machine-generated audit lines into the COMMENT stream. detectAnswer takes the LAST\n comment unconditionally, so an injected \"status changed to X\" line would be consumed as the\n human's answer. Beads keeps comments in their own table separate from the events audit log, so\n this is currently safe — preserve that separation.\n3. Move-then-comment, never comment-then-move (reconcile.go:376-379, :413-414, :1371-1373, :1494-1496).\n Only a successful move earns a comment, so a persistently failing move cannot spam one comment\n per tick.\n4. Persist-before-move; never assume a write landed and never re-read to confirm. Every failed move\n converges on a later tick (heal branch reconcile.go:286-299, alignCardToRecord:1436). This\n tolerance is what makes a non-transactional board safe.\n5. 'ready' MUST map to beads status 'open'. The atomic claim CAS hardcodes it:\n internal/storage/issueops/claim.go:47-58 UPDATE ... WHERE id=? AND status='open'. A custom\n 'ready:active' status would appear in bd ready but would NOT be claimable.\n\n== BEADS PUBLIC API (use it; do NOT import internal/ and do NOT shell out to the CLI) ==\nRoot package github.com/steveyegge/beads (MIT). Verified against the v1.1.0 source zip.\n Open(ctx, dbPath) / OpenFromConfig(ctx, beadsDir) -- the latter respects dolt_mode in\n metadata.json, so embedded-vs-server is CONFIGURATION not code.\n Storage interface maps ~1:1 to ports.Board:\n SearchIssues / GetReadyWork -\u003e Snapshot (SEE SPIKE: default limit unverified)\n UpdateIssue(id, {\"status\": ...}) -\u003e MoveToBucket\n AddIssueComment / GetIssueComments (typed, ordered) -\u003e Comment / Comments\n CreateIssue -\u003e CreateTask\n AddLabel + RemoveLabel -\u003e SwapLabel\n RunInTransaction(ctx, commitMsg, fn) batches many writes into ONE Dolt commit with rollback on\n error or panic — use it to (a) kill write amplification and (b) make SwapLabel ATOMIC, which is\n strictly better than the current Vikunja adapter's documented non-atomic add-then-remove.\n GetAllEventsSince(ctx, since time.Time) is a typed change-feed cursor — no hand-rolled SQL.\n RemoteStore (via type assertion) exposes Push/Pull so the daemon controls push timing itself.\nEscape hatches if the public API ever falls short, in order of preference: direct SQL (bd's own docs\nrecommend this for extensions); vendor the MIT-licensed code; a shim module declared under\ngithub.com/steveyegge/beads/\u003cx\u003e plus a replace directive (Go's internal rule is a path-prefix check\non the IMPORTING package path, so this legally compiles). internal/ carries no compat guarantees.","notes":"CORRECTION 2026-07-20 (verified against the v1.1.0 source zip from proxy.golang.org): blocker #1 'NO Go library' is WRONG. github.com/steveyegge/beads has a root package beads.go documented as 'a minimal public API for extending bd with custom orchestration', MIT licensed. It re-exports the internal layer via type aliases (Storage, Transaction, RemoteStore, SyncStore, Issue, Comment, Event, IssueFilter, WorkFilter, status/type constants) and exposes Open(ctx, dbPath), OpenFromConfig(ctx, beadsDir), FindBeadsDir, FindDatabasePath.\n\nThe Storage interface covers the Board port almost 1:1: SearchIssues/GetReadyWork -\u003e Snapshot; UpdateIssue(id, {status}) -\u003e MoveToBucket; AddIssueComment/GetIssueComments (typed, ordered) -\u003e Comment/Comments; CreateIssue -\u003e CreateTask; AddLabel+RemoveLabel -\u003e SwapLabel.\n\nThree risks in the description are downgraded by this API:\n- Write amplification: RunInTransaction(ctx, commitMsg, fn) batches many writes into ONE Dolt commit, rolls back on error or panic. Also makes SwapLabel ATOMIC — better than the current Vikunja adapter, which documents a deliberate non-atomic add-then-remove.\n- Change notification: GetAllEventsSince(ctx, since time.Time) is a typed poll cursor; no hand-rolled SQL over the events table needed.\n- Embedded-vs-server: OpenFromConfig respects dolt_mode in metadata.json, so switching is configuration, not code, and the daemon holds the connection instead of fork/exec-ing a ~250ms CLI. RemoteStore (via type assertion) exposes Push/Pull so the daemon controls push timing itself rather than inheriting the 5-min auto-push debounce.\n\nSTILL TO VERIFY before relying on it: which call enumerates ALL issues for Snapshot (there is no plain ListIssues — likely SearchIssues with an empty query + IssueFilter) and whether IssueFilter applies a default limit. A silently truncated snapshot makes the daemon treat missing cards as vanished and KILL live runs, so this needs an explicit completeness guarantee.\n\nUNCHANGED: the human write path is still the real blocker, and the recommendation stands — do not swap while the viewer is read-only.","status":"open","priority":3,"issue_type":"decision","owner":"bigbes@gmail.com","created_at":"2026-07-20T00:26:37Z","created_by":"Eugene Blikh","updated_at":"2026-07-20T07:48:03Z"} | {"notes":"UNBLOCKED by ah-wd4.1 (closed): the gating technical unknown is settled and it does NOT rule out a beads-backed Board adapter. beads v1.1.2's SearchIssues with a zero-value IssueFilter is an honest full enumeration — measured exact at 87, 700 and 2500 issues with no default page size — and it returns every status including closed and deferred, because the default hiding is CLI-side only. A safe Snapshot is buildable: count → list → count with a parity assertion and a REFUSAL on mismatch, mirroring internal/vikunja/board.go:95-99. See ah-wd4.1's close reason for the exact call, filter, assertion and the four traps (dead Offset, per-table limit fan-out, Statistics.TotalIssues excluding wisps, cross-table duplicate IDs).\n\nSo the decision is now a PRODUCT decision, not a technical one. The remaining questions for the full-swap-vs-hybrid-mirror call:\n- Vikunja's kanban board is the human interface. beads has a Dolt web UI, but does dragging a card between buckets have an equivalent? The whole reconciler design rests on 'the board is the desired state owned by the human', and a human moving a card is the primary input.\n- The Vikunja adapter is delivered, live-proven and defends against truncation already. What does the swap BUY — one datastore instead of two, and beads-native task hierarchy? Weigh that against re-proving a live path that currently works.\n- A hybrid mirror means two sources of truth for the same card and a sync direction to define. That is usually worse than either pure option unless one side is strictly read-only.\n- Note the notifier/board split: cards carry comments (result summaries, failure diagnostics, Q\u0026A answers, delegation reports). Does beads have a comment surface a human reads as naturally?"} | NULL | 2026-08-05T02:51:12Z |
| 019fcf31-5d81-7cd1-888e-af516a4badd7 | ah-g39 | created | Eugene Blikh | NULL | 2026-08-05T02:52:26Z | ||
| 019fcf31-5f72-734f-ab72-5d393767835c | ah-e5l | created | Eugene Blikh | NULL | 2026-08-05T02:52:27Z | ||
| 019fcf31-6149-7ccd-9890-ce345a004a4e | ah-i0v | created | Eugene Blikh | NULL | 2026-08-05T02:52:27Z | ||
| 019fcf32-f4d1-7c11-a5ae-97fd94625150 | ah-1cx.9 | status_changed | Eugene Blikh | {"id":"ah-1cx.9","title":"[bug] failure comment renders 'exit code 0' and drops the provider's rejection text","description":"Found by the independent review of 665e805 (ah-tqc).\n\nSince ah-tqc, an error outcome can carry exit code 0. failureComment (internal/reconcile/comments.go:209-214) prints the exit code whenever outcome == domain.OutcomeError, and its own doc comment says 'exit code (only for an error outcome, when the process actually produced one)' — an invariant ah-tqc silently broke.\n\nFor the exact live incident ah-tqc exists to fix, the operator now sees on the card:\n\n 🤖 attempt 1 failed · outcome `error` · exit code 0\n\nwith no diff stat (there is none) and nothing else. Meanwhile info.FinalErrorMessage — '401 {\"type\":\"error\",\"error\":{\"type\":\"ModelError\",\"message\":\"Model claude-3-5-haiku is not supported\"}}', the one string that makes this diagnosable in five seconds instead of an hour — goes only to the daemon's log.Warn in runner.go finalizeFromExit. The card is the operator-facing surface and the whole reason the bug mattered; 'error, exit code 0' reads like a daemon bug rather than a broken model config.","design":"Two changes, one of them cross-package.\n\n1. Gate the exit-code clause in failureComment on exitCode != 0, restoring the invariant its doc comment states. Cheap, local to internal/reconcile/comments.go.\n\n2. Thread FinalErrorMessage to the card. It is parsed in internal/runner (EventStreamInfo.FinalErrorMessage) and needs to reach failureComment, so it crosses ports.RunStatus → domain.Run → the comment. Decide deliberately whether it should also be PERSISTED on the run row (useful for /api/v1/status and for a post-hoc audit) or merely passed through to the comment. Persisting means a store migration; passing through means the information is lost on a refinalize, which reads the stored row rather than st. Truncate it — a provider can return a large body, and SPEC's comment-size discipline applies.\n\nDo this AFTER the ah-tqc follow-ups land in internal/runner, since they touch the same struct.","acceptance_criteria":"A run finalized as error with exit code 0 produces a card comment that names the provider's rejection and does NOT claim an exit code. A run with a genuine non-zero exit still shows it. Covered by a reconcile test asserting both shapes.","status":"open","priority":2,"issue_type":"bug","owner":"bigbes@gmail.com","created_at":"2026-08-04T23:42:47Z","created_by":"Eugene Blikh","updated_at":"2026-08-04T23:42:47Z"} | {"status":"in_progress"} | NULL | 2026-08-05T02:54:11Z |
| 019fcf32-f57f-7119-b170-d1b1c0d424c6 | ah-1cx.10 | status_changed | Eugene Blikh | {"id":"ah-1cx.10","title":"[bug] askAndPark has the same half-landed-park bug ah-1cx.2 fixed for delegation","description":"Found while fixing ah-1cx.2 and deliberately left alone there to keep that change reviewable.\n\naskAndPark parks the parent in Question the way delegateAndBlock parks it in Blocked, and it has the identical failure mode: if its UpsertTask(Question) — and its Yonote/question sentinel writes — fail AFTER the question comment is posted, the refinalize arm has no marker for it, so the parent routes to In Review and the question is silently dropped. The human is left with a card in review that is actually waiting on an answer nobody will give.\n\nah-1cx.2's shape is the fix: arm a durable kv intent marker BEFORE the first irreversible side effect, extract an idempotent park tail, and add a refinalize-only re-park arm that re-attempts the flip from the marker and never repeats the side effect. See reparkDelegatedParent / parkParentBlocked in internal/reconcile/reconcile.go as of 67dcf3c.","acceptance_criteria":"A failed UpsertTask(Question) leaves the card in In Progress with a loud comment and audit event, and a later tick re-parks it to Question without re-posting the question comment. Covered by tests mirroring TestDelegationParkPersistFailure.","status":"open","priority":2,"issue_type":"bug","owner":"bigbes@gmail.com","created_at":"2026-08-04T23:43:09Z","created_by":"Eugene Blikh","updated_at":"2026-08-04T23:43:09Z"} | {"status":"in_progress"} | NULL | 2026-08-05T02:54:11Z |
| 019fcf32-f633-76ab-94a3-e781f2fb1972 | ah-1cx.11 | status_changed | Eugene Blikh | {"id":"ah-1cx.11","title":"[bug] createChildTasks swallows RecordChild failures, orphaning a child from the join","description":"Found while fixing ah-1cx.2.\n\ncreateChildTasks logs and continues when RecordChild fails (internal/reconcile/tools.go:376). A child created on the board whose lineage row is lost is invisible to both ChildIDsOf and the parent's join: the parent parks in Blocked waiting on a set that does not include it, so the child can complete without ever unblocking the parent — and a human sees a blocked card with no visible reason.\n\nIt also weakens the before-count guard ah-1cx.2 added, which distinguishes a real pending join from fire-and-forget children by comparing the parent's child count before and after delegation.","acceptance_criteria":"A RecordChild failure either aborts the delegation loudly (card held, comment, audit event) or is retried durably. Either way the parent never parks on an incomplete child set. Covered by a test.","status":"open","priority":3,"issue_type":"bug","owner":"bigbes@gmail.com","created_at":"2026-08-04T23:43:10Z","created_by":"Eugene Blikh","updated_at":"2026-08-04T23:43:10Z"} | {"status":"in_progress"} | NULL | 2026-08-05T02:54:11Z |
| 019fcf32-f6e8-7d23-9418-066666f4cd8e | ah-1cx.16 | status_changed | Eugene Blikh | {"id":"ah-1cx.16","title":"runner: reject a blank rendered PROMPT.md at Start instead of burning an attempt","description":"Found by the adversarial review of ah-tqc and empirically reproduced against the installed pi 0.82.x.\n\npi's dist/modes/print-mode.js:94 guards the entire run with 'if (initialMessage)', so an empty or whitespace-only prompt makes pi do nothing and exit 0 with a one-line event stream containing only the session header. runner.prepareTaskDir writes s.Prompt verbatim with no non-empty check, and nothing in reconcile guards it either.\n\nah-tqc's second Errored() arm (SessionID != \"\" \u0026\u0026 AssistantMessages == 0) now CATCHES this, so the card correctly fails instead of sailing to In Review — but only AFTER the attempt is spent, a worktree is created, a zellij pane is spawned and an attempt number is consumed. A strings.TrimSpace(s.Prompt) == \"\" rejection in Start would fail it loudly and instantly with an actionable message naming the role and template.\n\nDeliberately left out of the ah-tqc fix because the check arguably belongs on the reconcile side of the boundary — the reconciler is what renders the template and knows which role/prompt file produced the empty result, so it can say WHY. Decide which side owns it: a Start-side guard is a cheap backstop that cannot explain itself, a reconcile-side check can name the template but leaves the port unguarded against a future caller.","acceptance_criteria":"A role whose template renders to whitespace fails before a worktree or pane is created, with an error naming the role and the prompt file. The ah-tqc Errored() arm stays as the backstop and its test still passes.","status":"open","priority":3,"issue_type":"bug","owner":"bigbes@gmail.com","created_at":"2026-08-04T23:46:15Z","created_by":"Eugene Blikh","updated_at":"2026-08-04T23:46:15Z"} | {"status":"in_progress"} | NULL | 2026-08-05T02:54:11Z |
| 019fcf32-f79a-7660-912d-556791e9f740 | ah-i0v | status_changed | Eugene Blikh | {"id":"ah-i0v","title":"stale package doc-comments: taskfiles.go, runner.go and httpapi.go describe a surface two stages old","description":"Found by the agent refreshing SPEC §9/§11 — the same staleness it fixed in the spec, still present in the code that spec describes.\n\n1. internal/runner/taskfiles.go:3-12 — the package header reproduces the OLD five-file .task/ layout and still says 'question.json reserved for Stage 4', in the very file that defines TasksFileName, SummaryFileName, PublishFileName, AnswerFileName and ToolAuthFileName.\n2. internal/runner/runner.go:8-10 — the package doc says 'PROMPT.md and meta.json are written at Start'; prepareTaskDir also writes tool-auth.json and the artifacts/ tree, and clears five outbound files plus publish/.\n3. internal/httpapi/httpapi.go:1-14 — the package doc lists only four routes and asserts 'The server trusts loopback (no auth in Stage 1)', never mentioning the three bearer-authenticated /api/tool/* routes that tools.go registers in the same package. That sentence is now actively misleading about the package's auth model.\n\ndocs/SPEC.md §9 and §11 are correct as of 119b84d; these three comments are what a reader hits FIRST when they open the code, so they should match.","acceptance_criteria":"Each of the three package doc-comments matches what its package actually does, and the httpapi one states the split between the unauthenticated loopback routes and the bearer-authenticated tools routes.","status":"open","priority":4,"issue_type":"chore","owner":"bigbes@gmail.com","created_at":"2026-08-04T23:52:28Z","created_by":"Eugene Blikh","updated_at":"2026-08-04T23:52:28Z"} | {"status":"in_progress"} | NULL | 2026-08-05T02:54:11Z |
| 019fcf32-f83a-7da8-93f2-50fe5a1dcee5 | ah-g39 | status_changed | Eugene Blikh | {"id":"ah-g39","title":"SPEC section 7: storage schema stale by four migrations","description":"Found during the ah-efe sweep; outside that epic's assigned sections, so filed separately.\n\n§7 documents the schema through v2 only — 'step 1→2 (Stage 2, schema v2) adds runs.timeout_seconds' — and its DDL block shows only tasks/runs/events. internal/store/store.go:94 has SIX entries in schemaMigrations: base, runs.timeout_seconds, task_parents, tasks.auto_routes, kv, yonote_qa.\n\nThis matters more now than it did: ah-efe.1 just documented the port methods those tables back (RecordChild/GenerationOf/ParentOf/ChildIDsOf, GetKV/SetKV, CreateQA/QAByTask/QAByComment/MarkQAAnswered), so §7 is the last place the storage layer still reads as Stage 2 — a reader who follows §6's Store interface into §7 finds no table behind half of it.","acceptance_criteria":"§7's DDL and its migration-step list match schemaMigrations element for element, with each step naming the feature and bead that introduced it. Verified against internal/store/store.go and the embedded schema.sql.","status":"open","priority":3,"issue_type":"chore","owner":"bigbes@gmail.com","created_at":"2026-08-04T23:52:27Z","created_by":"Eugene Blikh","updated_at":"2026-08-04T23:52:27Z"} | {"status":"in_progress"} | NULL | 2026-08-05T02:54:11Z |
| 019fcf32-f8d7-7674-9221-41dbcbb84af0 | ah-e5l | status_changed | Eugene Blikh | {"id":"ah-e5l","title":"SPEC section 15: security notes miss the entire Stage 4 credential surface","description":"Found during the ah-efe sweep; outside that epic's assigned sections.\n\n§15 is still headed 'Security notes (Stages 1-2)' and covers none of what Stage 3-4 added:\n- the per-task bearer tokens (256-bit, bound to (task id, attempt), in-memory registry only, constant-time compare, uniform 401 that echoes nothing, dropped on daemon restart);\n- .task/tool-auth.json at mode 0600 — the file that puts a live credential inside the agent's own worktree;\n- the Yonote bot token, and the token-in-URL vs token-in-header distinction between Telegram and ntfy that the redaction rules depend on.\n\n§9 and §11 now both POINT AT §15 for the 0600 rationale after the ah-efe refresh, so the gap is a dangling reference rather than merely an omission.","acceptance_criteria":"§15 is re-headed for the delivered stages and documents the bearer-token lifetime and blast radius, the 0600 tool-auth file, and every credential in the §1 services table with how each is transported and redacted. The §9 and §11 cross-references resolve to real text.","status":"open","priority":3,"issue_type":"chore","owner":"bigbes@gmail.com","created_at":"2026-08-04T23:52:27Z","created_by":"Eugene Blikh","updated_at":"2026-08-04T23:52:27Z"} | {"status":"in_progress"} | NULL | 2026-08-05T02:54:12Z |
| 019fcf32-f980-7b04-b949-d078af8d8d6f | ah-jzv | status_changed | Eugene Blikh | {"id":"ah-jzv","title":"config: export a Configured() predicate per optional block; stop re-deriving presence in the wiring","description":"Found while fixing ah-1cx.3.\n\nconfig's optional blocks each have an unexported present() predicate, so every consumer outside the package hand-rolls its own presence test against raw fields: cmd/agenthubd/main.go:180 (yonote client + boot identity probe), :192 (mem0 client + health probe), :292/:298 (notifier selection), plus internal/reconcile/{publish,qa,artifacts}.go reading cfg.Yonote.BaseURL directly.\n\nThey agree with present() TODAY only because resolve trims those base URLs — which is exactly the coupling that produced ah-1cx.3, where a whitespace-only ntfy.url was absent to validation and present to wiring. The next field added without a matching trim reintroduces the same class of bug.\n\nFix: export one predicate per optional block (e.g. func (y Yonote) Configured() bool wrapping the unexported present()) and have every consumer call it instead of restating the test. present() being unexported is precisely why the wiring hand-rolls it.","acceptance_criteria":"No consumer outside internal/config decides whether an optional block is configured by inspecting its raw fields; each calls the exported predicate. A grep for cfg.\u003cBlock\u003e.\u003cField\u003e != \"\" outside the package comes back empty.","status":"open","priority":3,"issue_type":"chore","owner":"bigbes@gmail.com","created_at":"2026-08-04T23:44:00Z","created_by":"Eugene Blikh","updated_at":"2026-08-04T23:44:00Z"} | {"status":"in_progress"} | NULL | 2026-08-05T02:54:12Z |
| 019fcf32-fa29-7d0f-9384-e3119d061fb7 | ah-1jy | status_changed | Eugene Blikh | {"id":"ah-1jy","title":"config: trim yonote.token and mem0.api_key at load like the notifier credentials","description":"Leftover from ah-1cx.3, which trimmed the four notifier strings (ntfy.url/token, telegram.token/api_base) at load so the value an adapter receives is exactly the value present() judged.\n\nyonote.token and mem0.api_key were not included. A padded credential passes the new blank scan correctly — it is not blank — but reaches the client with leading or trailing whitespace in the auth header and fails at REQUEST time rather than at load, which is the diagnostic gap ah-1cx.3 set out to close. The realistic source is a ${VAR} expansion picking up a trailing newline from an env file.\n\nAlso cosmetic, from the same family: resolve compares vikunja.web_url untrimmed when deciding whether to derive it from url. Post-ah-1cx.6 a whitespace-only value errors out instead of silently defeating the derivation, so this is now harmless — it is just the last untrimmed comparison left in the file.","acceptance_criteria":"A padded yonote.token or mem0.api_key is trimmed at load, and no untrimmed string comparison against \"\" remains in resolve.","status":"open","priority":4,"issue_type":"chore","owner":"bigbes@gmail.com","created_at":"2026-08-04T23:44:00Z","created_by":"Eugene Blikh","updated_at":"2026-08-04T23:44:00Z"} | {"status":"in_progress"} | NULL | 2026-08-05T02:54:12Z |
| 019fcf33-cdae-741e-8af4-a70dd8c9758c | ah-1cx.15 | updated | Eugene Blikh | {"id":"ah-1cx.15","title":"ahub-run should drop message_update while teeing: 95% of every event stream is unread","description":"Root cause behind the 202 MB event streams (ah-07g) that motivated ah-1cx.1, quantified during the ah-1cx.4 capture.\n\nIn a fresh real pi 0.82.1 run, message_update events were 123 of 143 lines and about 95% of the 166 KB. They exist so an interactive UI can render a partial message as it streams, and each one embeds the WHOLE partial message — so their volume grows quadratically with message length. Nothing in this codebase reads them: ParseEvents only consumes the session header and message_end.\n\nHaving ahub-run filter message_update out while teeing events.jsonl (or rotate/compress the file) would shrink real streams by roughly 20×, which would make even the full parse cheap and would retire the tail of this whole class of bug — ah-1cx.1's asymmetric read path, ah-1cx.9's repeated crash-path parse, and the disk footprint of every archived worktree.","design":"Decide first what the event stream is FOR. If it is only the daemon's fact source, filtering is free. If it is also a human debugging artifact (someone attaching to the pane, or reading an archived tarball to see what the agent was doing), then message_update is the only record of the streaming text and dropping it loses that — in which case rotation or compression is the right answer instead, or filtering with the raw stream kept separately under a size cap. The archive lane (internal/runner/archive.go) is the other consumer to check. Note ahub-run is the only writer, so this is a one-file change plus a decision.","acceptance_criteria":"A real multi-minute run's events.jsonl is an order of magnitude smaller, the daemon's parsed facts are unchanged, and whatever is lost for human debugging is stated explicitly in SPEC section 9.","status":"open","priority":3,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-08-04T23:43:44Z","created_by":"Eugene Blikh","updated_at":"2026-08-04T23:43:44Z"} | {"design":"EVIDENCE for the design decision, measured on a real pi 0.82.1 capture (the same run that settled ah-1cx.4):\n\nLine-type histogram over 143 lines / 166 KB:\n 123 {\"type\":\"message_update\",... \u003c- 86% of lines, ~95% of bytes\n 4 {\"type\":\"message_start\",...\n 4 {\"type\":\"message_end\",... \u003c- the only ones ParseEvents sums\n 2 {\"type\":\"turn_start\"}\n 2 {\"type\":\"turn_end\",...\n 2 {\"type\":\"tool_execution_update\",...\n 1 {\"type\":\"tool_execution_start\",...\n 1 {\"type\":\"tool_execution_end\",...\n (plus session, agent_start, agent_end, agent_settled)\n\nKEY IMPLEMENTATION FACT: 'type' is the FIRST key on every single line, so a filter can decide from a short fixed-length prefix and never has to buffer a whole line. That matters because message_update embeds the whole partial message, so lines grow with the conversation — the parser's own maxEventLine cap is 10 MB. A filtering writer should therefore be prefix-driven and stateful ('am I inside a line I already decided to drop?'), NOT line-buffering, or it reintroduces the memory problem it exists to solve. The current tee is a raw byte MultiWriter set as cmd.Stdout (cmd/ahub-run/main.go:129-141), so this means inserting a small stateful io.Writer in front of the FILE half only.\n\nRISK NOTE: events.jsonl is the file every completion fact is read from — session id, cost, and since ah-tqc the final-turn verdict. A filter bug corrupts the daemon's entire view of a run. Whatever lands must (a) keep the output a valid JSONL stream, (b) never drop a line whose type it did not positively recognise as message_update (fail OPEN, not closed), and (c) be covered by a test that replays testdata/pi-events-multi-real.jsonl's raw form through the filter and asserts ParseEvents gets byte-identical facts.\n\nOPEN DECISION, needs an operator answer before coding: is events.jsonl only the daemon's fact source, or also a human debugging artifact? message_update is the ONLY record of the streaming assistant text as it was produced; dropping it means a human reading an archived worktree tarball sees the final messages but not the process. If it is only a fact source, filtering is free. If humans read it, prefer rotation/compression, or filter the teed copy while keeping the raw stream separately under a size cap. internal/runner/archive.go is the other consumer to check."} | NULL | 2026-08-05T02:55:06Z |
| 019fcf34-1ff9-72f9-8ade-890847c38d63 | ah-1cx.8 | updated | Eugene Blikh | {"id":"ah-1cx.8","title":"[bug] cost_usd is 0 for every live run — pi prices from its own registry, the litellm provider has none","description":"Split out of ah-tqc's second finding so that bead can close on its first finding.\n\n/api/v1/status reports cost_usd=0 for ALL runs on agent-1, including real multi-minute ones (observed on tasks 1-2 during the 2026-07-19 Q\u0026A smoke). The run row's CostUSD comes from ParseEvents summing message.usage.cost.total over assistant message_end events.\n\nRULED OUT: the parser. A real pi 0.82.1 capture against the DIRECT deepseek provider (deepseek/deepseek-v4-flash) puts cost.total exactly where ParseEvents reads it, and the summation is arithmetically correct (proven per-message, not cumulative — see ah-1cx.4).\n\nLEADING HYPOTHESIS: pi computes cost itself from a per-provider model pricing registry. agent-1 runs every role through a CUSTOM OpenAI-compatible provider named 'litellm' (role models are 'litellm/\u003cmodel\u003e'), and a custom provider carries no pricing metadata, so pi emits cost{input:0,output:0,total:0} while token counts are still real. If that holds, nothing in agent-hub is broken and the fix is to stop trusting pi for cost: either derive it from token counts against a locally configured price table, or read spend from the LiteLLM proxy (it tracks per-key spend), or drop the field from the status API rather than reporting a confident zero.","design":"Settle the hypothesis BEFORE writing code. Needed evidence, from agent-1 (the local ssh probe is blocked by the permission classifier — needs an operator '!' handoff):\n\n1. A real events.jsonl from a completed run: jq -c 'select(.type==\"message_end\" and .message.role==\"assistant\") | .message.usage' over the archived stream. Are totalTokens non-zero while every cost.* is 0? That confirms the hypothesis outright.\n2. The pi provider config on the box (how the 'litellm' provider is declared) — does pi's config format accept per-model pricing for a custom provider? If it does, the cheapest fix is config-only, on the box, with no code change at all.\n\nOnly if both come back negative does this become a parser/shape bug.","acceptance_criteria":"Either (a) pi is configured/patched so cost.total is real for the litellm provider and a live run reports a non-zero cost_usd, or (b) cost is sourced from somewhere trustworthy, or (c) the field is explicitly documented+surfaced as unavailable rather than a silent 0. In all three cases SPEC and the status API description must match what is actually reported.","status":"open","priority":3,"issue_type":"bug","owner":"bigbes@gmail.com","created_at":"2026-08-04T23:32:21Z","created_by":"Eugene Blikh","updated_at":"2026-08-04T23:32:21Z","labels":["milestone:v0.1.0"]} | {"notes":"OPERATOR HANDOFF — the ssh probe from this dev session is blocked by the local permission classifier, so these need a '!' handoff or a human at a terminal. Run on agent-1 (or via ssh from the mac):\n\n1. Find a completed run's event stream and look at what pi actually reported:\n ssh agent-1.lab.internal\n sudo -u agenthub bash -lc 'ls -t /opt/agent-hub/work/*/task-*/.task/events.jsonl 2\u003e/dev/null | head -3'\n sudo -u agenthub bash -lc 'jq -c \"select(.type==\\\"message_end\\\" and .message.role==\\\"assistant\\\") | .message.usage\" \u003cPATH\u003e'\n The decisive question: are input/output/totalTokens NON-ZERO while every cost.* is 0? That confirms the hypothesis outright — pi is reporting real token counts and no price, i.e. it has no pricing metadata for the custom 'litellm' provider.\n (If the worktrees are already pruned, the archive lane keeps tarballs — check archive_dir, default \u003cwork_root\u003e/archive, for task-\u003cid\u003e.tar.gz.)\n\n2. Look at how the litellm provider is declared in pi's config on the box:\n sudo -u agenthub bash -lc 'ls ~/.pi; grep -rn litellm ~/.pi 2\u003e/dev/null | head'\n Does pi's config format accept per-model pricing for a custom provider? If it does, the cheapest fix is CONFIG-ONLY, on the box, with no code change in agents-dev at all — and this bead closes as 'not our bug, fixed in pi config'.\n\n3. Cross-check what LiteLLM itself thinks the spend was, since it tracks per-virtual-key spend. If pi cannot be taught the prices, LiteLLM is the trustworthy source and the fix becomes 'read cost from LiteLLM, not from the event stream'.\n\nOnly if step 1 comes back with cost fields that are populated-but-mis-shaped does this become a parser bug — and in that case capture the events.jsonl and add it as a testdata fixture, the way ah-1cx.4 did for the 0.82.1 shape.\n\nFor reference, a REAL pi 0.82.1 capture against the DIRECT deepseek provider (local mac, 2026-08-05) has cost exactly where ParseEvents reads it: message.usage.cost = {input, output, cacheRead, cacheWrite, total}, all non-zero, and the arithmetic checks out against flat per-Mtok rates. So the parser is not the problem on a provider pi has prices for."} | NULL | 2026-08-05T02:55:27Z |
| 019fcf38-ea48-7357-80a2-118316b8c63f | ah-jzv | closed | Eugene Blikh | Done in baa8075. Each optional block's presence rule lived only in an unexported present(), so every consumer outside the package restated it against raw fields — cmd/agenthubd/main.go at the yonote client + identity probe, the mem0 client + health probe, and both arms of the notifier-selection switch. Those raw != "" tests agreed with present() ONLY because resolve happens to trim the fields they read; that coupling is what produced ah-1cx.3, where a whitespace-only ntfy.url was absent to validation and present to the wiring and killed a boot. Added an exported Configured() on AgentsView, Telegram, Ntfy, Yonote and Mem0 — each a one-line wrapper over present(), so there is exactly one definition of presence and validate() and the wiring can never diverge. AgentsView.Configured carries the full doc comment (Configured() is the only sanctioned presence test outside the package, plus the ah-1cx.3 boot failure that justifies the rule); the other four state the rule and point at it. All four cmd/agenthubd call sites migrated, comments rewritten to record why. Purely additive: present() stays, no signature changed, go build ./... passes for every package. Cover: table-driven TestConfiguredPredicates asserting Configured()==present() for absent, single-field-set and whitespace-only inputs per block (the whitespace rows are exactly where a raw != "" test would disagree), plus an end-to-end check through Load. FOLLOW-UP NOT DONE (internal/reconcile was owned by another agent at the time), split into its own bead: reconcile.go:2010 agentsViewLink is the one real drift risk left — its raw test silently ignores a set-but-blank machine and renders a link ending in '~pi:' rather than no link; reconcile.go:1996, publish.go:83/93/114, qa.go:83/203 and artifacts.go:77/89 are correct today because they are gated upstream by r.yonote != nil, which main.go now sets from Configured(). The Configured() doc comment also asserts a rule no test or lint enforces — the acceptance grep is verified by hand. | NULL | 2026-08-05T03:00:41Z | |
| 019fcf38-efab-73f3-bc55-22db659513c2 | ah-1jy | closed | Eugene Blikh | Done in baa8075. yonote.token and mem0.api_key were the two credentials ah-1cx.3 missed when it trimmed the four notifier strings. A padded credential is NOT blank, so it passes the set-but-blank scan correctly, but it then reached yonote.New / mem0.New with the whitespace still attached and went into the Authorization / X-API-Key header verbatim — failing at REQUEST time with a 401 far from the config file, which is precisely the diagnostic gap ah-1cx.3 set out to close. The realistic source is a ${VAR} expansion picking up the trailing newline of an env-file line. Both are now strings.TrimSpace'd in resolve, in the same literals that already trim their base URLs, so the value the client receives is exactly the value present() judged. The same change removes the last untrimmed == "" comparison in resolve — the vikunja.web_url derivation test — but NOT naively: trimming that test alone would have REGRESSED, because a whitespace-only web_url would then silently take the value derived from vikunja.url, where today it defeats the derivation and gets caught by ah-1cx.6's http(s) check. So vikunja.web_url was added to blankScanFields and is now reported at load by name, and the scan's doc comment records the move. Cover: a vikunja.web_url row in TestLoadBlankValues's sibling table, plus three subtests pinning that padded yonote.token / mem0.api_key — literal and ${VAR}-expanded — arrive trimmed. | NULL | 2026-08-05T03:00:42Z | |
| 019fcf39-39db-7756-96c7-faf906e671c6 | ah-pdj | created | Eugene Blikh | NULL | 2026-08-05T03:01:01Z | ||
| 019fcf39-3bc9-7ad1-9fd8-c2b0ac20df63 | ah-w4r | created | Eugene Blikh | NULL | 2026-08-05T03:01:02Z | ||
| 019fcf3b-833a-7af0-b8b1-028b145b20b3 | ah-tls | created | Eugene Blikh | NULL | 2026-08-05T03:03:31Z | ||
| 019fcf3b-b6c0-7484-b870-4bb209968c48 | ah-g39 | closed | Eugene Blikh | Done in 6b7bacf. §7 documented the migrator as if it stopped at v2 and its DDL block showed only tasks/runs/events, so task_parents, kv and yonote_qa — three of the tables §6's Store interface is written against — had no schema behind them at all, and tasks.auto_routes was missing from the tasks DDL even though it is in taskColumns and every SELECT. §7 now enumerates all schemaMigrations elements with the feature and the introducing bead: 0→1 base schema.sql (ah-nyl.2), 1→2 runs.timeout_seconds (ah-xuc.4), 2→3 task_parents (ah-0ge.1), 3→4 tasks.auto_routes (ah-4el), 4→5 kv + yonote_qa in ONE element (ah-ptu). The DDL block is now the EFFECTIVE schema after all five steps, with a header note that schema.sql itself is frozen at the v1 shape and does not contain the two ALTERed columns, which are marked inline. CORRECTION TO THIS BEAD'S OWN PREMISE: schemaMigrations has FIVE elements, not six — kv and yonote_qa are two CREATE TABLEs inside one element — so schemaVersion == 5 and §7 was stale by three migrations, not four. New prose covers auto_routes never resetting, task_parents's ON CONFLICT(child_id) idempotency and its ok=false-means-root contract, kv's missing delete (cleared to "") and its two key shapes, and yonote_qa's comment_id UNIQUE spawn guard. It also states plainly that there is NO CREATE INDEX anywhere — the only indexes are the ones SQLite derives from PRIMARY KEY/UNIQUE, so ChildIDsOf and any events.task_id scan are table scans — and that kv (one delegation_park row per delegating attempt) and events both grow unboundedly with nothing pruning them. | NULL | 2026-08-05T03:03:44Z | |
| 019fcf3b-fafe-7f07-88d8-96145d5747ec | ah-e5l | closed | Eugene Blikh | Done in 6b7bacf. §15 was headed 'Stages 1-2' and described a credential surface predating the entire Stage 3-4 story; §9's 'it carries a bearer token (§15)' pointed into text that said nothing about bearer tokens. §15 is now headed 'Stages 1-4' and opens with an EXPLICIT THREAT MODEL — daemon, zellij server, ahub-run, pi and worktrees are all one unix user, loopback is an assumption not a boundary — so the rest reads as 'don't write secrets down' rather than as containment. It sweeps every credential in §1's services table as a list with transport and redaction: Vikunja token (Bearer header; errors carry method/path/status/body-prefix), webhook secret (inbound HMAC, constant-time, uniform 401), LiteLLM key (never reaches the daemon), Telegram (token IN THE URL, redactURLError unwraps the *url.Error), ntfy (token in the HEADER so the URL is safe, plus the NO_PROXY carve-out), mem0 X-API-Key, the Yonote BOT token (Bearer header, APIError never carries headers, bot identity owns every published doc and Q&A reply), sourcehut SSH (daemon never handles key material), and AgentsView (no credential). Dedicated paragraphs document the per-task bearer token — 32 bytes of crypto/rand hex-encoded, bound to one (task id, attempt), mint idempotent per attempt, invalidate at finalize, in-memory-only registry so a restart drops every in-flight token, subtle.ConstantTimeCompare with no early break, uniform 401 that echoes nothing, blast radius of exactly three calls on one task — and .task/tool-auth.json at 0600: the one live credential inside the agent's own worktree, defense in depth rather than a boundary, attempt-scoped and cleared so tools_api:false leaves no stale credential and tool calls fail closed, git-excluded, but carried into ArchiveWorktree's owner-only tarball with an already-invalidated token. Also added: the three routes that remain unauthenticated on purpose. ONE CLAIM HAD TO BE CORRECTED RATHER THAN EXTENDED — the old 'the pi process inherits only the env it needs' is FALSE (nothing sets cmd.Env anywhere), so every token the daemon expands is readable from inside the agent's pane. The reality is now written down and the fix is filed as a P1 bead. Also reported, not fixed: internal/store MarkQAAnswered's docstring claims it makes delivery once-only but the SQL has no 'AND answered_at IS NULL' — the property actually comes from the caller's guard in reconcile/qa.go:385; and internal/httpapi/tools.go:178 puts err.Error() into the 500 body, the one place the tools API echoes internal detail, inconsistent with the constant-body discipline the same file applies to auth failures. | NULL | 2026-08-05T03:04:02Z | |
| 019fcf3e-89b7-75e3-9b39-5a138205defc | ah-1cx.15 | closed | Eugene Blikh | Done in c956724. DECISION MADE AND RECORDED: filter the FILE half of the tee only, leave stdout raw. The evidence that this loses nothing is that the final message_end carries the COMPLETE content array (verified on the pi 0.82.1 capture: thinking + text/toolCall, whole), so every message survives in full and only the superseded redraw increments are dropped; and a human watching LIVE is looking at the pane, which is untouched. That made the 'is the stream a human debugging artifact?' question answerable without an operator: it is, and filtering does not damage it. Implementation (cmd/ahub-run/eventfilter.go): a stateful prefix-driven io.Writer wrapping the file, inserted into the MultiWriter's file half. Prefix-driven rather than line-buffering because a single message_update can reach megabytes and buffering one would reintroduce the memory problem this exists to solve — pi writes 'type' as the FIRST key on every event, which is what makes a fixed 24-byte decision window possible. FAILS OPEN: a line is dropped only when its opening bytes positively match the marker, so anything unrecognized, malformed, split oddly or truncated passes through — events.jsonl is where every completion fact comes from (session id, cost, and since ah-tqc the final-turn verdict), so dropping too much costs correctness while dropping too little only costs disk. Write() reports the full input consumed even though fewer bytes reach the sink, because io.MultiWriter treats a short write as io.ErrShortWrite and would abort the tee. Close() flushes an undecided trailing partial line (a SIGKILLed child mid-write) rather than discarding evidence of how the run died. The load-bearing test is chunk-independence: cmd.Stdout hands the filter whatever a pipe read returns, so the marker can arrive split anywhere inside its 24 bytes. The suite replays each stream through EVERY single split point and one byte at a time, plus the fail-open cases (a space after the key, the marker as a longer type's prefix, the word as a later value, garbage/blank lines, lines shorter than the marker) and a 4 MB update line asserting the buffer never grows past the marker length. MEASURED end to end on the real capture, out of band: 166814 → 8212 bytes (20.3x, 4.9% kept), 143 → 20 lines, byte-identical to the same stream filtered by jq. SPEC §9 step 1 records the filter, the fail-open rule and these numbers. Knock-on: this shrinks real streams enough that ah-1cx.14 (the crash-confirmed full parse repeating every poll) stops being a starvation risk in practice, though the O(stream) call is still there. | NULL | 2026-08-05T03:06:50Z | |
| 019fcf3e-fefc-79d7-8073-a987a1e5d09d | ah-w4r | status_changed | Eugene Blikh | {"id":"ah-w4r","title":"config: trim vikunja.url and vikunja.token, and blank-scan agent_tasks.target_bucket","description":"The last of the untrimmed-credential family, found while doing ah-1jy.\n\n1. vikunja.url and vikunja.token are still stored untrimmed. A padded url errors via ah-1cx.6's http(s) check so it is visible, but a padded token goes to the board API verbatim in the Authorization header and fails at request time — exactly the failure class ah-1jy just fixed for yonote.token and mem0.api_key, and on the one service that is MANDATORY.\n\n2. resolve still has one untrimmed string comparison left outside ah-1jy's scope: 'if cfg.AgentTasks.TargetBucket == \"\"' (config.go around line 641). Trimming it ALONE would be a regression — 'target_bucket: \" \"' currently produces a clear 'is not a canonical bucket name' error, and a trimmed test would silently apply the 'triage' default instead. It needs a blankScanFields entry in the same change, exactly the way vikunja.web_url got one in baa8075.","acceptance_criteria":"A padded vikunja.token arrives trimmed at the board client; a whitespace-only agent_tasks.target_bucket is reported by name at load rather than silently defaulting. No untrimmed string comparison against \"\" remains in resolve.","status":"open","priority":4,"issue_type":"chore","owner":"bigbes@gmail.com","created_at":"2026-08-05T00:01:02Z","created_by":"Eugene Blikh","updated_at":"2026-08-05T00:01:02Z"} | {"status":"in_progress"} | NULL | 2026-08-05T03:07:20Z |
| 019fcf40-d4b7-7860-b29a-5bf7820173f3 | ah-w4r | closed | Eugene Blikh | Done in b96e3a6. The last of the untrimmed-credential family (ah-1cx.3 → ah-1jy → this), and the only ones on a MANDATORY service. A padded vikunja.url was at least visible — validateHTTPURL rejects it (ah-1cx.6) — but a padded vikunja.token went to the board API verbatim in the Authorization header and failed at request time with a 401 far from the config file. Both are now trimmed in resolve, and url is trimmed BEFORE web_url's derivation so whitespace cannot leak into the derived value; web_url's own comparison is now a plain == "" again, since the trim happens above it. agent_tasks.target_bucket was resolve's last untrimmed emptiness test: trimming it ALONE would have been a regression, because 'target_bucket: " "' currently produces a clear 'is not a canonical bucket name' error and a trimmed test would silently apply the triage default instead — so it joins blankScanFields in the same change, exactly as vikunja.web_url did in baa8075. blankScanFields' doc comment now states the rule outright: trimming an emptiness test in resolve and adding its field to the scan are ONE change, never two. Cover: an agent_tasks.target_bucket row in TestLoadBlankValues's sibling table, a padded-vikunja-credentials subtest that also asserts the derived web_url comes out clean, and a padded-target_bucket subtest asserting padding is trimmed rather than rejected. | NULL | 2026-08-05T03:09:20Z | |
| 019fcf41-7ede-760e-bcf7-8b75f5439616 | ah-a0y | created | Eugene Blikh | NULL | 2026-08-05T03:10:03Z | ||
| 019fcf41-80c2-7524-b16e-8e3d0a992550 | ah-46h | created | Eugene Blikh | NULL | 2026-08-05T03:10:04Z | ||
| 019fcf42-0485-7b8d-a6fc-fab4bff9e63b | ah-jzv | label_added | Eugene Blikh | NULL | NULL | Added label: milestone:v0.1.0 | 2026-08-05T03:10:38Z |
| 019fcf42-05d6-70b3-a234-607faecc9d60 | ah-1jy | label_added | Eugene Blikh | NULL | NULL | Added label: milestone:v0.1.0 | 2026-08-05T03:10:38Z |
| 019fcf42-0739-70af-8237-eadeada52040 | ah-w4r | label_added | Eugene Blikh | NULL | NULL | Added label: milestone:v0.1.0 | 2026-08-05T03:10:38Z |
| 019fcf42-089d-7505-b8b2-75439675d3a0 | ah-g39 | label_added | Eugene Blikh | NULL | NULL | Added label: milestone:v0.1.0 | 2026-08-05T03:10:39Z |
| 019fcf42-0a53-7795-b895-6a8872bdb766 | ah-e5l | label_added | Eugene Blikh | NULL | NULL | Added label: milestone:v0.1.0 | 2026-08-05T03:10:39Z |
| 019fcf42-0bde-7d65-a928-9791e2df1688 | ah-1cx.15 | label_added | Eugene Blikh | NULL | NULL | Added label: milestone:v0.1.0 | 2026-08-05T03:10:39Z |
| 019fcf42-0d2e-7d6d-80ad-02f43e15ba7f | ah-efe.5 | label_added | Eugene Blikh | NULL | NULL | Added label: milestone:v0.1.0 | 2026-08-05T03:10:40Z |
| 019fcf42-0e80-7ebd-a00b-55e18f02c732 | ah-efe.4 | label_added | Eugene Blikh | NULL | NULL | Added label: milestone:v0.1.0 | 2026-08-05T03:10:40Z |