Chronological notes from development sessions. Most recent first. See CLAUDE.md for the project context and ROADMAP.md for the phased TODO.
Every Publish run since July was a workflow_dispatch. nightly.yml built on a cron, but as a pre-release, which only nightly-channel users receive. A fix that landed on main reached the stable channel when someone remembered to click Run workflow. Stable is on the cron now.
publish.ymlgained the schedule;nightly.ymlis gone. Same 03:00 UTC slot. Keeping both would have built the same commit twice a night under two version numbers. The full matrix ships (macOS arm64+x64 signed and notarized, Windows, Linux), which also closes the ROADMAP gap where an Intel install could hit updater errors when the newest release was an arm64-only nightly.- The skip gate compares against the latest stable release, not a 24h window.
gh release view(/releases/latest, pre-releases excluded) names the tag and the compare API says how far main is ahead of it. A night the cron misses is caught up the next night rather than waiting for another commit. Dispatch and tag pushes always build, as before. The gate was run verbatim against the live repo: 3 ahead ofv0.2.51builds, the tag's own commit skips. - A
concurrencygroup queues a dispatch behind the schedule. Theversionjob reads "highest release so far" at run time; two runs in flight would stamp the same version and race to create one release. - The channel picker is now a no-op. Both channels receive the same nightly stable build. Left in place so a pre-release track can return without a client change; removing it, or the "every build as it lands" copy in Settings → About, is a follow-up.
No new secrets: the scheduled path runs with the signing and notarization secrets Publish already had.
Follow-up from reviewing #53. Session 93 bounded external_submission_lost; the ladder's OTHER non-answer had the same shape and was missed.
retrymeans the CALL failed, not that the queue answered — a 5xx, a network blip, or a permanent condition that simply isn't a 403.decideSubmitAftermathhandled it withensure('queued')and spent nothing, so a permanent condition was retried on every evaluation for as long as the PR sat in the queue. Quiet rather than loud (the call fails before GitHub, so no label and no comment), which is why it outlived the noisy one.- Its own budget, not
submitAttempts(submit_retry_attempts, migration 0045, reset by R2 with the rest). The two answer different questions:submitAttemptsmeans "stop spending QUEUE cycles on an unchanged commit" and is read to explain what the provider did with the PR. A call that never reached the provider is a different fact, and letting a couple of transient blips eat the real submit budget would block a healthy PR out of doors that still work. - The known instance is now classified at the source.
apiRequestthrowsGitHub not connected for this workspacewhen neither an installation token nor a user token resolves — not a 403, not transient. Both doors read it asno_mechanismwith "Reconnect GitHub in Settings", so the PR stops with the right answer instead of after three pointless calls. The budget still stands behind it for whatever the next unrecognised permanent failure turns out to be. - Why this matters more after #53. That PR sends the label door as the connected USER (
preferUser), which skips the installation token with no fallback — so a missing or dead user token turns a working door into exactly this failure. The bound and the classification are what make that safe to merge.
Tests: both sides of the budget in decide.test.ts plus an assertion that the provider-facing submitAttempts is never touched by it, and the disconnected classification in the ladder suite (including that it does not quietly try the next door with a dead credential).
Session 93's follow-up established that Talyn's App IS authorised with trunk: it takes /trunk merge from talyn-app[bot] and answers "Submitted to Merge by talyn-app[bot]" (#85100, #84450, #84471, #84422). That is true of the COMMENT channel. It is not true of the LABEL channel.
- Trunk checks the two channels differently, for the same App. The label Talyn applied on #82679 got "Only users that are a part of this repo's Trunk organization or have write permissions to the repo can submit a PR to the queue", and trunk deleted the label. The command from the same App, on other PRs, was accepted. So door 2 is refused where door 1 is not, and no App permission changes that — the check is about who submits.
- Door 2 now applies the label as the connected GitHub user, through the
auth: 'user'modeapiRequestalready had. That account has write access to the repo, which is what trunk's message asks for. Door 1 keeps using the App: it works, and it is what trunk records as the submitter. - Unverified, deliberately shipped anyway. Nobody has applied the label by hand on a gated repo to confirm trunk then accepts it. The downside is bounded: trunk deletes the label either way, Session 93's budget stops the retries, and door 1 is the door that normally answers now that the command memo survives a deploy.
- A 403 on that call now has two causes — the account may lack write access, and the App may lack
Issues: Read & write, since a user-to-server token is still bounded by the App's permissions. The message names both.
Known gap, not addressed here: accountKeyFor keys the REST rate-limit gate on the installation id whatever token a call resolves to, so a block earned by a user-token call throttles installation traffic and vice versa. Pre-existing, and it needs a change in apiRequest's gate keying rather than in this path.
Tests: the label door asserted to go out as 'user' in the ladder suite and through the pipeline in evaluator.test.ts; the 403 message pinned to naming both causes.
Opening the submit-label door (Session 92) exposed the path underneath it. On PostHog/posthog#82679: 61 label events and 38 provider comments in one hour, one cycle every four seconds, in a channel PostHog engineers watch.
- Trunk refuses the SUBMITTER, not the PR. "An error occurred while submitting your PR to the queue:
Only users that are a part of this repo's Trunk organization or have write permissions to the repo can submit a PR to the queue" — Talyn's App is not authorised with that repo's Trunk org. Trunk answers each attempt by deleting the submit label, which is what turned a static permission problem into a loop. - The per-head submit budget only existed on the ejection path.
decideExternalEjectionenforces it; the label vanishing is not an ejection, so it fell to R5b'sexternal_submission_lost→queued→ submit again, with no budget read anywhere. That branch now blocks at the samemaxAttempts, with a reason that leads with the check a human can make ("the queue may not accept submissions from Talyn's GitHub App"). Same per-head reset via R2 — a new commit is the one thing that plausibly changes the answer. - The bound is deliberately provider-agnostic, and that is the whole point. This is the branch ANY provider behaviour Talyn does not recognise falls into, so it must not be able to spend the same door forever. A parser rule for trunk's specific sentence would have fixed this one case and left the shape intact.
- A parser rule was written and then reverted, which is the more useful lesson. Recognising the error comment looked obvious — until
externalQueueStatusFromComments("last recognised comment wins") plus trunk posting a NEW comment per error meant the error would permanently outrank trunk's real status comment, which is edited in place at its original position. Every affected PR would have been pinned intorejectedforever, including after a human fixed the permission. The fix that changes no parsing is the safe one.
Tests: the two sides of the bound in decide.test.ts (unspent → back in line; spent → blocked_manual + notify + no resubmit, and the reason names the App).
A wrong conclusion, corrected in the same session. The first reading of trunk's error was "Talyn's App is not authorised with Trunk at all". It is not: talyn-app[bot] posts /trunk merge and trunk answers "✨ Submitted to Merge by talyn-app[bot]" — verified on #85100, #84450, #84471, #84422. Trunk applies a stricter permission check to the LABEL channel than to the comment channel, same App. Nothing needs granting in the Trunk org; the command door has been working the whole time.
Which makes the real fault the memo, not the label. #82679 has no trunk instruction comment (trunk rewrote it), so door 1 could not read the command off the PR — and the per-repo memo Session 86 built for exactly that case was a process-local Map, wiped by each of the four deploys that day. So door 1's fallback was empty too and the ladder fell to the label, which is the door trunk refuses. The memo is now a table (external_queue_submit_routes, migration 0044), loaded at boot, with the Map kept in front as a read cache so the hot path stays synchronous. Session 86's comment reasoned carefully about staleness and never mentioned process lifetime, which is the thing that actually broke it — and a cold memo does not degrade to a slower door here, it degrades to a worse one.
Writing that turned up a second defect: getPoolDbClient() throws SYNCHRONOUSLY with no pool, so an unguarded persist propagates up through the comment read that feeds it and takes out the very door it is remembering. The whole call is guarded, not just the promise; six existing submit tests caught it by silently falling through to auto-merge.
Tests: externalQueueSubmitRoute.test.ts — the property that was missing (wipe memory, reload from the table, door still there), repo scoping and casing, a changed command keeping one row, and "comment traffic must not become write traffic" (a repeated command writes once).
A steady stream of "can't merge — there is no way to submit the PR to it automatically: the repo refuses GitHub auto-merge and defines no submit label. Needs manual intervention." The message was wrong on its own terms. posthog/posthog defines trunk-merge-queue-submit, and /trunk merge is sitting on a dozen open PRs right now.
listRepoLabelNamesfetched one page.?per_page=100, no pagination. posthog/posthog defines 271 labels andtrunk-merge-queue-submitis at position 254, so the merge queue's most reliable door has been invisible on that repo since the probe shipped. Every PR there fell through the whole ladder tono_mechanism→blocked_manual→ notify. It now uses thepaginatehelper the rest of the service already had.- A truncated list is worse than a failed call.
getExternalQueueSubmitLabelcaches the answer as a definitenullfor an hour, and the block quotes it to the user as fact. A thrown call would at least have been retried and logged. - And the verdict never expired.
blocked_manualis sticky by design — only a dequeue/requeue clears it — which is right for a block about the PR. "No mechanism can submit this" is not about the PR: it is about the REPO's configuration and about what Talyn could SEE of it, so it can be falsified with nothing about the PR changing, which is exactly what fixing the probe does. Without a way to retire it, the fix would have left every PR the bug touched needing a manual requeue, one at a time. R5c now retires the verdict when a door is observed to exist (ctx.externalSubmitDoor— the cached label probe or the remembered command, asked ONLY for an entry sitting in that block, so it costs nothing anywhere else). - Left alone deliberately:
listIssueCommentshas the same single-page shape, and the same failure is available in theory — trunk's comment posted late on a PR with 100+ comments would be invisible and read as "not submitted". It is not a live problem (the affected PRs carry 5–9 comments) and paginating a hot-path read on every evaluation costs real REST budget, so it stays a known edge rather than a speculative fix.
Tests: listRepoLabelNames across three pages with the submit label on the last one and the short-page stop asserted; the heal and its negative in decide.test.ts. One unrelated flake in the run (authMiddleware timeout test, 10.8s under load) — passes alone.
Seven approved PRs showed an amber Queue: not ready, and every one of them was fine: checks still running, zero failures, nothing for a human to do. The badge read like a problem and hid the one number that answered it.
not_readyis the only queue state that is about the PR, not the queue. Trunk holds the submission and says it "will be added to the merge queue once all branch protection rules pass" — so on posthog it is the state of every submitted PR for the whole ~40 minute CI run, not an exception. Session 90 renamed it correctly (it had been reading asqueued) and the pill's amber clock then applied to the normal case.- The queue pill outranks every open-state verdict, which is right for the states the queue owns (
testing,passed,failed— "Ready" would be a lie on a branch only trunk can merge). It is wrong fornot_ready, where the thing trunk is waiting on is exactly what the ordinary verdict describes, and the ordinary verdict says WHICH part. Sonot_readynow defers when the PR explains itself — checks running, checks failing, a conflict, changes requested — and only claims the pill on a PR with nothing left to report, where the queue genuinely is the remaining answer. Same shape as the existingnot_submittedfall-through. - A Requeue button already exists (PR detail → Merge queue), shown on
blocked/blocked_manualonly, which is the whole set of states where resetting the budgets does anything. None of the seven had it because none were blocked.
Tests: the four-way matrix in PRStatusPill.test.tsx — defer on running, defer on failing, claim the pill when the PR is clean, never defer on testing. Note the harness gotcha the first draft walked into: the component short-circuits the queue path unless state="open" is passed, so the two "defers" cases passed vacuously without it.
PostHog/posthog#84450 sat in the merge queue for 9½ hours with three required checks red and nothing happening. No fix run fired, no notification, and the badge said "Queued" — the same thing it says for a PR waiting its turn.
- The deadlock. Trunk's comment read "✨ Submitted to Merge … It will be added to the merge queue once all branch protection rules pass". The parser mapped that to
queued,isExternalQueueHolding('queued')is true, and R5b stands down on every holding state (Session 87 — a fix run's push ejects a PR trunk is testing, destroying ~40 minutes of CI). So trunk waited for the PR's branch protection to pass, and Talyn refused to fire the run that would make it pass. Neither side could move. - Trunk was saying the opposite of what Talyn read. "It will be added to the merge queue once…" means it holds the SUBMISSION and the PR is not in the queue. That is
not_ready, and the parser now says so. Nothing is running, no batch exists to eject the PR from, and the branch protection trunk is waiting on is exactly what a fix run produces. - "Has the PR" is not "is working the PR".
isExternalQueueHoldinganswers the first — it decides whether the entry belongs inawaiting_external, andnot_readystill belongs there. The question R5b actually needs is the second, and it is now its own predicate:externalQueuePushWouldEject=queued | testing | passed. Those are the states where a commit costs the provider real work.not_readyfalls through to the settled-blocker test instead: remediated when it has a blocker, left waiting when it doesn't. - Session 87's reasoning is intact for the states it was about. It swept
not_readyin as part of "every holding state" and its own argument — that a push destroys a test cycle — never applied there. The test that pinned it (does NOT push at a trunk-not-ready PR) is now its opposite, with the deadlock spelled out. - No migration.
merge_queue_entries.external_stateis re-derived from the provider's comment on every evaluation, so the stuck entries re-read asnot_readyon the next 60s reconciler tick. The desktop already renders that state better than the one it replaces: an amber clock and "trunk's merge queue is holding this PR until it meets the merge requirements", rather than a plain "Queued".
Tests: the parser body (verbatim from #84450), externalQueuePushWouldEject across every state with the invariant that anything it flags is also holding, the two new decide cases (not_ready + blocker → fire_fix_run; not_ready + clean → wait), and the evaluator end-to-end.
Follow-up in the same session — R5d and R11 fought over the entries that stayed blocked. Removing R5b's brake let a not_ready entry reach R11, and on a PR whose fix budget was already spent the recurrence guard blocked it — which R9 had been doing correctly all along. R5d then parked it back into awaiting_external, out of the status R9 keys on, and the pair rewrote the entry twice per evaluation: #84471 logged 100 events inside one minute, alternating blocked ⇄ awaiting_external. R5d now leaves a blocked entry alone when parking it would not HOLD it — !externalQueuePushWouldEject. On testing/passed/queued it still parks, because R5b holds it there and the badge is the better information; on not_ready the entry keeps its block and the reason a human needs, and nothing below R5d runs, so it still cannot push or merge under the provider.
PostHog/posthog#84477 read as green in the panel and to the merge queue, while GitHub showed "4 failing checks" and refused the merge. The counts were not stale — a refresh re-fetched the same data and re-derived the same wrong verdict. The classification was wrong.
CANCELLEDhad aCheckStateof its own, and no bucket counted it. The pill breakdown is{total, passed, failed, inProgress, skipped}, so a cancelled check landed intotaland nowhere else. On #84477 no context on the head commit had aFAILUREconclusion at all: the two blockers were both cancelled, and one of them (shellcheck) is REQUIRED. Sochecks.failedwas 0,blockingReasonnever becamechecks_failed,prNeedsFollowupwas false, and the queue held the PR as passing. GitHub rollsCANCELLEDup asstatusCheckRollup.state = FAILUREand lists it under "N failing checks"; branch protection is never satisfied by one.normalizeCheckStatenow returnsfailure, andCheckStatedrops bothcancelledandneutral(the latter was never produced) — the type is now identical to the wire typePRCheckStatein@talyn/client, which never had those members. The backend can no longer emit a state the front ends cannot count.- The rollup reconciliation was blind to it too.
summarizeCheckContextsrestores a live failure when GitHub's rollup says FAILURE but latest-per-name shows none. It searches the raw contexts forstate === 'failure', and on #84477 there were none to find — the restore ran and restored nothing. With the cancelled runs reading as failures, the de-noised view already holds them and the branch is not needed. - Migration
0043moves the rows written under the old mapping.pr_check_statesfeeds the incremental (webhook) counts and is only rewritten when a newcheck_runevent arrives for that(repo, sha, name)— which a cancelled run will not send. Left alone, every affected PR kept miscounting until its head moved, and the 5-min sweep's correct counts were overwritten by the stale rows on the next unrelated check event for the same sha.pull_requests.last_summaryneeds no rewrite: the sweep replaces that blob wholesale. - The tiles and the list came from different fetches. The detail sheet read its counts off the cached row and its rows off the live detail fetch, so the two could disagree — "1 Failed" over a list with no failing check in it, which is what the bug report showed. Both now come from the live detail when it has landed, with the cached counts as the fallback.
Verified against the live PR: before, {total: 257, passed: 148, failed: 0, inProgress: 1, skipped: 106} — the buckets summed to 255, not 257. After: failed: 2, the buckets sum to the total, shellcheck is named as the required failure, and the verdict is checks_failed.
Tests: the #84477 shape end-to-end in githubGraphql.test.ts (a cancelled required check, no FAILURE conclusion anywhere → checks_failed), the same through the webhook parser in checkCounts.test.ts, and a buckets-sum-to-total assertion in both — that invariant is what the old behaviour broke. The test that pinned the old behaviour ("counts only cancelled checks toward total, not the sub-buckets") is gone.
Every guard in the pipeline was per PR. MAX_INFRA_SUBMITS_PER_HEAD bounds one commit's resubmits and the recurrence signature bounds one head's ejections, but nothing could see that the QUEUE was the broken thing. So a backlog of queued PRs each rediscovered a dead runner independently and spent its own budget doing it.
That is worse than wasted spend, because trunk batches. Every submission into a sick queue joins a batch that will fail and then be bisected, so the PRs still feeding it were lengthening the outage for the PRs already in it. The useful move is the one no single entry can decide alone: stop submitting, and wait.
services/repoQueueHealth.tsis the third instance of the shaperepoMergeGate.tsandrepoSigning.tsalready use: a small tally that decays and re-earns itself, with no restart in any recovery path. It is scoped to(repo, base)— the merge queue's own group key — and deliberately NOT to a workspace, followingexternalQueueState's argument that the provider's state is a property of the repo rather than of who is looking at it. Two workspaces watching posthog/posthog are watching one queue.- Counted by PR, never by failure. One PR resubmitting through its own infra budget is one PR having a bad day; three DISTINCT PRs failing the same way inside the window is the queue. Counting failures instead would have let a single unlucky PR condemn a healthy queue.
- One-sided, like the classifier that feeds it. Only a positively identified
infrastructurefailure counts against the queue. Anything Talyn could not classify stays the PR's own problem, so the worst this can do on unfamiliar output is nothing at all. - It gates SUBMISSION and nothing else. A PR with a real local blocker still gets its fix run while the queue is sick — that work is useful the moment the queue recovers, and holding it back would waste the outage. A PR already in the queue is left alone; it is past the point this rule speaks to.
- No notification. This fires across many entries at once by construction, and one notification per PR is exactly the noise the feature exists to remove. The blocked reason carries it in the UI instead, and it names the queue rather than the PR, so nobody goes looking for something to fix in a PR that has nothing wrong with it.
- Recovery needs no human. The window ages observations out, and any merge clears them outright — a merge being the only direct proof the queue works. The block is
blocked, notblocked_manual, and is released by its own rule rather than waiting for a push, because nothing about the entry caused it and nothing about the entry can clear it. - Fed by the transitions the pipeline already writes, rather than a second observation path that could disagree with the timeline.
applyTransitionnotes the two infrastructure event codes against the queue and clears the record on any merge, however that merge was reached.
Worth knowing. The thresholds (3 distinct PRs, a 30 minute window) are a first guess, not a measurement — nothing has run this against a real outage yet. They are exported so a future session can tune them against what merge_queue_events actually recorded, and the one-sided classifier underneath means the failure mode of a bad threshold is a queue that keeps submitting, not one that stops when it should not.
Talyn could read every state trunk publishes and still walk into the one thing trunk punishes: a push. A PR sitting in the queue would get a cloud fix run dispatched at it, the run's fix would land as a commit, and trunk would answer 🚫 This pull request was removed from the merge queue because it was pushed to by @x. Talyn parsed that sentence perfectly — it just had no rule against causing it. The cost is a whole test cycle (~40 minutes at PostHog) plus the paid run that bought the ejection.
- The trigger was the ordinary shape of a reviewed PR, not an edge case. R5b let a "settled blocker" through the
awaiting_externalshort-circuit on the theory that the provider would otherwise hold a broken PR forever.prNeedsFollowupcounts an unresolved review thread, and bot reviewers leave those on nearly every PR, so the escape hatch was the common path. The premise was also wrong for trunk, which ejects a PR it cannot merge on its own (waiting to become mergeable for too long … Submit it again once it's ready) — and THAT is the moment remediation is both safe and useful. Hands off now while the provider is holding the PR, onnot_ready/queued/testing/passedalike. - The stand-down is on OBSERVED holding, never on our own submission record. With no answer from the provider, nothing says a queue is testing the PR, and parking on our own bookkeeping would strand a PR whose provider never comments.
stillSubmittedkeeps deciding that case exactly as before. - R5b only ever covered PRs TALYN submitted. Every other route into the queue — the author commenting
/trunk merge, a PR queued in Talyn after it was already submitted, a submit from the desktop merge button — left the entry inqueuedwhile trunk had the PR, and it walked straight into the rules that push and merge. R5d now states the rule on the provider's state instead of ours: if the queue is holding the PR, the entry belongs inawaiting_externalwhatever it currently says. It skipsblocked_manual(which emits no actions anyway, and is meant to be sticky) and holds rather than parks while one of our own runs is mid-flight, since that run's push is already coming and R8's accounting lives below the rule. - Gated bases only, because trunk's labels outlive it.
externalQueueOffalls back to labels, and PostHog carries staletrunk-testingon PRs that merged hours ago; parking on one in a repo where trunk was switched off would wedge every entry with nothing left to un-wedge it. The executor'sexternalStateMaxAgenow returns the 10-minute backstop for any entry on a gated base rather thannull— R5d needs the comment channel's answer for entries that never submitted, and on a gated repo trunk comments on every PR, so the webhook feed almost always has it already. - The keep-mergeable watcher knew nothing about any of this. It stood down on
mergeQueued, which is TALYN's queue — a PR the author submitted to trunk themselves is not in it, so the watcher kept ticking every 60s and firing the same pushing run. It now checks the gate (cheap, cached, and what keeps every ordinary repo off the second call) and then the queue state, standing down only on the holding states. A read that fails answers "no": a queue we cannot see must never wedge the watcher. - A queue failure cost a whole extra queue cycle before Talyn did anything. The ejection path only escalated to a fix run once the SAME ejection had been seen twice, so the first one resubmitted the identical commit. That is not one PR's CI: trunk batches, so a resubmit buys a batch re-test, a bisection to find this PR at fault again, another ejection, and every PR batched alongside it waiting through all of it. A
failedstate means the queue RAN the tests and this commit lost, so it is acted on the first time now. The other ejected states are untouched, because a "pushed to by @x" or a "waiting to become mergeable for too long" taught us nothing about the code. The flake is what this trades against and it is the cheaper side: a wasted cloud run is minutes, andqueueFailureRulealready tells the run to report that it found nothing rather than push a guess. - The run was told "it failed tests" and had to rediscover which check broke. Trunk names the failing checks in a markdown table under the status sentence, or inside a link in the single-check shape, and
statusEvidencekeeps only the sentence — so both were parsed and dropped.ExternalQueueStatus.failedCheckscarries them now, into the queue-failure prompt ("Start with those specific ones") and intoqueueSignature, where the same check failing twice is a dead end and a different check is progress. - The resubmit loop was unbounded in both of its bounds.
queueSignaturehashed trunk's verbatim sentence, and trunk interpolates the pusher and the batch PR into exactly the two reasons that repeat (pushed to by @dmarchuk,PR #84396 was used for testing) — so two identical ejections read as two different reasons and the recurrence guard never once fired. Signatures now compareexternalQueueReason, which strips@handleand#nnnand keeps the check name, since a different failing check IS a different problem. Behind it,submitAttemptshad been counted since the day it shipped and never read: theexternal_queue_rejecteddoc promises a block "more times than the per-head budget allows" and the desktop renderssubmits: n/3, and neither had anything behind it. It is enforced now, and self-heals on a real push like every other per-head budget.
Worth knowing. The failure mode this trades into is a PR that parks in awaiting_external when no queue actually holds it, and that is the right direction — waiting costs latency, while pushing costs a test cycle and a paid run to destroy it. Every path into it is bounded: a holding reading needs positive evidence from the provider, the gate itself decays (Session 77), and the comment channel overrides a stale label the moment trunk speaks.
Two desktop notifications on PostHog/posthog PRs that were, in fact, healthy. Both came from the trunk.io integration, and both were Talyn's reading, not trunk's behaviour.
- "No way to submit the PR automatically" on a PR trunk already had (#84433). The submit ladder's first door reads the command out of trunk's instruction comment — but trunk keeps ONE comment per PR and rewrites the body in place, so the instruction (and the
/trunk mergetext with it) disappears the moment trunk accepts the submission, and most of its failure bodies carry no command either. With no submit label on posthog/posthog and no auto-merge on a gated branch, every door was shut, andno_mechanism→blocked_manual/external_gate→ notify fired on a PR sitting in the queue. Two fixes: the ladder now checks the provider's own comment FIRST and returnsalready_submittedwhen the queue is holding the PR (newSubmitOutcome, decide tracks it asawaiting_externalinstead of blocking, and nothing is posted twice); and the command is remembered per repo (services/externalQueueSubmitRoute.ts, fed for free by every comment list andissue_commentwebhook the state cache already sees) so a RESUBMIT can find the door on a PR whose own comment no longer names it. The memo is only ever consulted when the provider's comment proves it owns that PR. - A fix run and then a block, for a Docker port collision (#85338, #85284 — one trunk run failed both). Trunk's queue run died in "Apply postgres and clickhouse migrations and setup dev" with
failed to bind host port for 0.0.0.0:50052 … address already in use; no test ran and both PRs were green on their branches. The old path read the repeat as "the same reason twice", spent aqueue_failurecloud run on it, and blocked with a reason implying the author had broken something. The comment parser now keeps the Actions run/job trunk linked (ExternalQueueStatus.failureUrl),services/externalQueueFailure.tsreads that job's steps, and a failure whose every failing step is setup/teardown (or a job that failed with no failing step at all) is classifiedinfrastructure.decideExternalEjectionthen RESUBMITS rather than escalating, does not record the signature (an infrastructure death is not a reason the PR can defeat), and stops atMAX_INFRA_SUBMITS_PER_HEAD— 4 submissions ≈ two hours of trunk cycles — with a reason that names the runner, not the PR. The classifier is one-sided on purpose: anything it cannot positively recognise staysunknownand behaves exactly as before.
Tests: externalQueueFailure.test.ts (the real #85338 job shape, the infra/test step split, a mixed job staying unknown, run-level links, the per-job memo, every "cannot tell" path), plus the failure-link parser and externalQueueCommentPresent in externalMergeQueue.test.ts, the ladder's already-submitted and remembered-command paths, the decide policy in mergeQueue/decide.test.ts, and both end-to-end in mergeQueue/evaluator.test.ts. The evaluator suite now resets the repo/PR-scoped caches between tests — PR numbers restart at 1 there, so an observation from an earlier test was answering for a different PR.
Landing a stack meant merging the bottom PR, waiting, retargeting the next one by hand, waiting for CI, merging, and repeating. The queue could not help, and the reason was structural: its serialization unit is (repositoryId, baseBranch), and every PR in a stack targets a different base by definition. So each member was a group of one, each was simultaneously isHead, and nothing ordered them — a stack member merged into its parent's branch whenever it went green.
- The gate is an unconditional decide rule (R4b), between the draft rule and the auto-merge rule. It has to be, because the group walk gives no protection here: parent and child live in different groups, are walked by two independent (possibly cross-replica) evaluations, and
decideCleanPathnever readsctx.isHead. Ordering withindecideis load-bearing in both directions — below R0/R1/R3 so a child that merged, closed, or crashed mid-merge terminates rather than parks; above R5..R11 so a parked child never arms auto-merge, is submitted to trunk (which refuses stacks outright), fires a fix run, updates its branch, or merges.awaiting_stackalso joins the auto-merge disarm invariant, and that is the case that actually bites: a parked child holding a Talyn arm gets merged by GitHub into its PARENT'S branch the moment checks pass. - The parent edge is derived, never persisted. One query per group walk, because the group key IS the base branch, so every entry in a walk shares the answer (
services/mergeQueue/stack.ts). Aparent_pull_request_idcolumn would have been the same class of unmaintained denormalization that letbase_branchrot. Hop 1 is deliberately state-agnostic — a merged parent is exactly what triggers the retarget — while every hop above it follows open parents only, matchinglinkStack. base_branchwas rotting, and the feature depends on it. It was written only at enqueue; nothing maintained it, despite a schema comment claiming the evaluator did. A retargeted PR was stranded in a group nothing walked, and its signing / external-gate probes ran against a base it no longer targeted. Every evaluation now reconciles it and BAILS — the whole decision context belongs to the base just left. Shipped first, on its own.- A successful retarget aborts the evaluation rather than redeciding.
requiresSignedCommits,getExternalMergeGateandgetAutoMergeCapabilitywere all probed against the old base, which is an unprotected feature branch; the base it moves TO is the real base, where those rules actually live. The retarget also writes the new base into the PR row's summary in the same breath — otherwise the entry and the row disagree untilrefreshPrlands, and the next evaluation's reconcile flips the entry straight back, a ping-pong that burns the retarget budget until the entry blocks. - Nothing scheduled a parked stack. Every trigger a parked child has keys on the base it is parked on, which is the parent's HEAD branch, and the parent's own events are about a different
(repo, base)pair. The snapshot event now carriesheadBranch, and a terminal snapshot schedules the group named by it. POST /:id/merge-queue/stackresolves the chain server-side from any member (a stale client can never enqueue an unrelated PR), root-first. Dequeue always cascades UPWARD — every descendant is parked on this PR. The free-plan gate is all-or-nothing under one advisory lock: a partial stack is not a degraded success, because the retarget of rung 4 only happens because rung 4 is in the queue, so it would stop halfway with nothing to say why.- Stack linking moved to
@talyn/shared(linkStack/ancestorsOf/descendantsOf), structurally typed sosharedstays free of client types. It had lived only in the renderer, derived per render; a second copy in the backend would have diverged into "the UI says these are stacked but the queue doesn't".buildStackedRowskeeps its own sorting and depth — that part is presentation — and its existing tests passing unchanged is the guard. - The wire addition is three things:
awaiting_stack,stackParentNumber, andsetMergeQueueStack. Everything else a stack UI needs is derivable from the open rows the client already holds. The one thing derivation cannot give is the parent of a PR that has ALREADY been retargeted — its branch link is gone by definition — which is exactly whatstackParentNumberis for. - The Merge Queue page groups by where a PR LANDS, not by its own base. Only stacks care, and only because every member targets a different base: grouped naively a five-PR stack rendered as five sticky headers, each holding one row, each labelled
#1. Extracted to a purequeueGroupsmodule so that is a test rather than a bug report.
Two things to know. Default merge method is squash, so when a stack's root squash-lands the base gets one new commit while the child's branch still carries the parent's originals — update_branch then conflicts, or worse succeeds and re-shows the parent's changes in the child's diff. Talyn has no checkout and cannot rebase; the existing conflict → cloud-fix-run path can, and now gets an explicit rebase hint naming the parent and the new base. Defaulting stack enqueue to method: 'merge' would sidestep it entirely and is still open. Second: an N-deep stack pays N serial CI cycles by construction — on a repo with a ~40 minute cycle a four-deep stack is hours, which the queue header says out loud so it is not filed as a bug.
A customer using auto-keep-mergeable reported that the run applied every bot review comment without pushback. Two things were true: the default prompt said "if the feedback is correct or reasonable, implement it" (an agent will almost never "disagree" unless told that is an expected outcome, and nothing distinguished Greptile from a human reviewer), and there was no way for a workspace to change any prompt Talyn builds.
- Every prompt is now a template.
packages/shared/src/promptTemplates.tsholds the shipped defaults (mergeable,skill), the variable catalogue with legend text, a ten-line{{name}}renderer, andvalidatePromptTemplate. The dynamic and provider-specific pieces are variables ({{gitRules}},{{baseUpdateFlow}},{{loopRules}},{{issues}},{{resignRule}}, ...), so ONE template serves PostHog Code and Claude Code;buildMergeablePrompt/buildSkillPromptjust pick the variables per provider and render. Values are inserted once and never re-scanned, so a SKILL.md full of mustache renders untouched. Empty blocks (resignRulewhen nothing needs signing) vanish with their blank lines. - A workspace can replace a prompt wholesale.
workspaces.settings.prompts.<kind> = { template, basedOnHash, updatedAt }, no migration. The PATCH merges that key one level deeper (save or reset one kind without resending the others;nullresets) and the merge runs in SQL (settings || patch,jsonb_set+jsonb_strip_nullsfor the prompts level) so two overlapping PATCHes cannot clobber each other's keys, which the old read-then-write did. It validates on save (unknown variables, missing required ones such as{{gitRules}}and{{pr.url}}, size cap) and 400s.basedOnHashis the FNV hash of the default the user forked from: when the shipped default later changes, the UI says so instead of silently keeping them on stale text. Backend call sites (startPrMergeableRun, the auto-keep watcher, both merge-queue executors) read it viaservices/promptTemplates.ts; the desktop and web fix/skill buttons read it from the workspace already in the store. - Settings → Instructions (desktop + web fork): a card per prompt with Default/Customized state and Reset, and an editor dialog with Edit / Preview / Default tabs. The variable legend is clickable and inserts at the caret (blocks land on their own line,
lib/promptEditor.ts), shows which variables are in use, and marks required ones red when missing. Preview renders the template against a real tracked PR for either provider. "Copy into editor" on the Default tab is the "start from source" path. - The default step 1 changed too. Bot and automated reviewers are advisory: verify the claim against the code, apply only real defects / security / correctness / clear convention violations, push back with a reason otherwise, never widen scope on a bot's say-so. Human reviewer feedback keeps priority.
Tests: promptTemplates.test.ts (renderer, validation, hash, overrides through both builders, the bot policy), routes/workspaces.test.ts (PATCH validation matrix, overlapping PATCHes), the override reaching the created task from every backend caller (prAutoMergeWatcher.test.ts, prCloudFix.test.ts, mergeQueue/evaluator.test.ts, mergeQueueProcessor.test.ts) and both front-end fix/skill buttons (useGitHubActionsConnect.test.tsx, useGitHubActionsPrompts.test.tsx), InstructionsSettings.test.tsx + promptEditor.test.ts in both front ends.
Some orgs run a bot that reviews and stamps a PR once it carries a label. Talyn now adds a workspace-configured set of GitHub labels to every PR the auto-keep-mergeable watcher is watching, so that bot picks up exactly the PRs Talyn is driving. Setting: workspace.settings.autoKeepMergeableLabels (a string[]; the Settings card takes a comma-separated field under "Auto-keep new PRs mergeable" in both renderers, parsed by the shared parseAutoKeepMergeableLabels, deduped case-insensitively since GitHub label names are).
The labels are applied inside the watcher tick (prAutoMergeWatcher.ts ensureLabels), not at arm time. One place covers every way a PR becomes watched (the toggle route, the workspace default on first sighting) and it also backfills PRs that were already watched when the setting was filled in, and picks up a label added to the list later. The watcher records what it has applied in autoMergeState.appliedLabels and adds only the diff (compared ignoring case, since GitHub label names are), so a label the user removes from the setting stays on the PR: Talyn never removes labels, since the bot may already have acted on them. Cost is one settings read per workspace per tick and one addPullRequestLabels per PR that is missing something.
Failure is best-effort and bounded: a refused write (typically the App lacking issues: write) is logged, nothing is recorded, and that repo is skipped for 15 minutes so a permission problem doesn't cost a GitHub call per watched PR per minute. Re-arming a PR resets its state and re-applies the labels, which is idempotent on GitHub's side.
Tests: prAutoMergeWatcher.test.ts ("watch labels": the diff matrix incl. partial application and casing changes, labelling while a run is in flight, backoff on refusal and its expiry, per-workspace lists in one tick, malformed stored state), autoKeepMergeableLabels.test.ts (the parser and the stored-value normalizer) and autoKeepMergeableLabelsField.test.tsx in both renderers (commit on blur/Enter, no-op when unchanged, clear sends [], failed save toasts and resets).
GitHub renders a ```mermaid fence as a diagram. Our PR detail sheet showed the source as a code block, so any PR that explains itself with a picture arrived as unreadable text. The fix is in lib/markdown.tsx (both forks), so the agent transcript and the review bodies get it too.
Three decisions worth keeping:
- The swap happens at the
<pre>, not the<code>. react-markdown gives a fence as<pre><code class="language-mermaid">. If you intercept the inner<code>, the diagram stays trapped in a monospace, pre-wrapped box.mermaidSourceFromPrereads the child element's class and returns the source, or null for every other fence. The class test is anchored ((^|\s)language-mermaid(\s|$)), solanguage-mermaidishis still a code block. - mermaid is loaded on demand. It is megabytes of JavaScript, and most PR bodies hold no diagram.
lib/mermaid.tsximports it dynamically and caches the module promise. Verified in both builds: the Vite entry chunk contains no reference to mermaid, and the webpack renderer keeps it in split chunks. It also loads under the app CSP (script-src 'self', nounsafe-eval) — checked in a real browser, because jsdom cannot run mermaid at all (nogetBBox). securityLevel: 'strict'is what replaces the sanitizer. Diagram source is untrusted: anyone who can open a PR against a watched repo controls the string. The SVG goes in withdangerouslySetInnerHTML, which walks straight past therehypeSanitizepass thatmarkdownSanitize.test.tsxpins. Strict mode runs mermaid's own DOMPurify over the output and turns off HTML labels andclickdirectives. mermaid'ssecurelist stops a%%{init: …}%%directive in the source from lowering it. Both test suites assert the setting, so a later "the diagram would look nicer withloose" has to argue with a red test.
Failures are shown, not swallowed. A diagram that does not parse renders the reason and its source, so the body still reads the way it does on GitHub minus the picture. suppressErrorRendering stops mermaid appending its own error SVG to <body>, outside React's tree, where nothing would ever clean it up. The theme follows the app: a MutationObserver on the dark class re-renders on a theme flip, and the always-dark agent feed pins the dark mermaid theme whatever the app theme is.
Tests: apps/desktop/src/__tests__/mermaid.test.tsx (18 cases, mermaid stubbed — it is ESM and jest transforms to CommonJS) and apps/web/src/__tests__/markdownMermaid.test.tsx (5 cases through the real markdown pipeline).
Connecting PostHog Code asked the user for two things they shouldn't have to handle: a personal API key, pasted into our window, and a project (team) id they had to go and find. PostHog has been a full OAuth2/OIDC authorization server for a while, its tasks API accepts pha_ bearer tokens with exactly the same scope and per-team enforcement as a personal key (posthog/permissions.py branches on neither), and it supports CIMD — so the client_id is a document we host at https://www.talyn.dev/oauth-client and there is nothing to register and no client secret to obtain. Talyn is a public client; PKCE is the protection, which PostHog requires of every client anyway.
Nobody is migrated. An existing install is on authMethod: 'personal_api_key' — or has no authMethod at all, which reads as the same thing, and that's the shape every pre-OAuth row has — and keeps its card, its Edit button, and its key. New connections lead with OAuth, with "use a personal API key" one click away, and it stays the only option on a deployment without POSTHOG_OAUTH_* set (self-hosted, local dev). The pair is all-or-nothing, the POLAR_* pattern: absent means the flow isn't offered anywhere, which is also the kill switch.
The project id stops being a form field. required_access_level=project makes PostHog's consent screen render its single-project picker, and self-introspection (RFC 7662 — allowed with no introspection scope when a token introspects itself) reports the choice back as scoped_teams. So the project is a property of the grant. A grant covering a whole organization, or several projects, is refused at connect time with a message rather than resolved by picking the first one: filing every future task into the wrong project silently is worse than one clear error, and the scope ask stays at openid task:read task:write as a result.
The real work was the token lifecycle, not the flow. PostHog issues 1-hour access tokens and 30-day refresh tokens, rotates the refresh token on every use, and enforces reuse protection with a 120-second grace — after which presenting a spent refresh token revokes the whole token family. Talyn calls this API from a poll loop, a streamer and the dispatcher, across two instances during every deploy. So the naive version doesn't fail a request; it logs a workspace out of a connection nobody touched. Refreshes are single-flighted twice: an in-process promise map collapses the many-callers-one-instance case with no round-trip, and a blocking advisory lock (posthog-oauth-refresh:<ws>) covers the deploy overlap — blocking rather than try-lock because the loser must wait and re-read the rotated pair, not skip and reuse a spent one. invalid_grant is terminal and sets oauth.reauthRequiredAt on the row so every surface says "Reconnect needed" and nothing retries a grant that can't come back; a 5xx explicitly does not set it, or a PostHog blip would tell every workspace to reconnect.
PKCE states live in Postgres (migration 0040), not in the process-local Map the older GitHub App flow uses. A lost GitHub state costs a re-click; a lost state here loses the code_verifier, which is the thing that makes the returned code redeemable — and a callback landing on the instance that didn't mint it is not hypothetical, it's every deploy. Single-use by construction: the lookup is a DELETE … RETURNING, so a replay finds nothing.
Two smaller things worth remembering:
- The web app must navigate the current tab, not open one.
lib/openExternal's own docs say it:window.openis granted only while user activation is live, and awaiting the authorize URL spends it, so a popup would be silently blocked on the Settings screen. The desktop opens the system browser (the user's PostHog session is there) and picks the result up on window focus — which meantuseSystemStatushad to start re-checking PostHog on focus at all, since until now those credentials could only change from inside the app. - Switching auth methods drops the other method's stored credential. Connecting via OAuth clears
apiKeyEnc, and saving a key clears the tokens. A leftover encrypted key that a revoked OAuth grant could silently fall back to is a credential the user believes they replaced.
Tests: posthogOauth.test.ts (30 cases — PKCE derivation, single-use state, the exchange body, project resolution and its two refusals, the refresh path, the concurrent-refresh collapse, terminal-vs-transient failure, and both legacy row shapes resolving as personal-API-key). See docs/CLOUD_PROVIDERS.md for the module map and docs/SETUP.md §6b for the env pair and the three ways to misconfigure it.
The Debug panel was streaming backend internals across every account from inside both customer-facing apps, behind a Settings toggle, maintained in two byte-identical copies. Meanwhile the fleet — hosts, microVMs, goldens — was invisible from the product: fleetd binds loopback and is only reachable over the tailnet, and the one endpoint we exposed (GET /fleet/hosts) was rendered by nothing. Both problems have the same answer, which is a third browser app that is not the product.
apps/admin is a fork of apps/web, deployed to admin.talyn.dev, gated on the is_admin boolean that already existed. It holds fleet operations, cross-tenant product admin, the audit trail, and the Debug panel — which was moved, not copied: a zero-diff git mv, because its three relative imports resolve unchanged at the destination and a pure rename is reviewable as a move.
The read surface degrades; the write surface does not. This is the one decision everything else follows from. The fleet page is the page you open because a host is misbehaving, so every read goes through a probe() that cannot throw — an unreachable box renders as a row with a reason, and one dead host never takes out a healthy one. Mutations are the deliberate exception: an unreachable drain is a 502, because "the drain probably worked" is how a box stays live through an incident somebody believes they drained. A stale host is never dialled at all — registration does not imply reachability, so there is no point burning the timeout to learn what the registry already said.
The recurring failure mode this console had to design against is an unknown value rendered as a definite one. A host that never reported a memory budget must not read as 100% full, because the response to that is draining a healthy box. runsMax: 0 is unknown, not zero-capacity. A Go zero-value timestamp is "never", not 01/01/0001. An empty table and a failed request must never look the same — "no hosts have reported" is a fact about the fleet, "we couldn't reach the backend" is a fact about us, and an operator may go and restart something on the strength of confusing them. That is offlineBanner.test.tsx's lesson with much higher stakes, and it recurs at three layers: the access gate, the query hook, and every table.
Mutations are a stack of small specific refusals, not a permission model. There is one operator, so a roles table would be a model nobody administers. Instead: a reason (persisted verbatim — a gate that drops the value is theatre), a self-mutation guard, confirm-by-typing-the-target's-email, a last-admin check inside the transaction, and TALYN_ADMIN_GRANT_ENABLED defaulting to off so a stolen operator session can read and comp — bad, but auditable and reversible — and cannot mint a second operator. Guard order is load-bearing and tested: exists → not-self → deploy-permits → confirm, because checking confirm first lets someone probe for accounts by watching which error came back.
The audit log (admin_audit_log, migration 0039) has two write shapes because the two side effects have different rollback stories. A remote call cannot be rolled back, so the row is written as pending before dialling and settled after — if the backend dies mid-call the trail still says "we were about to drain hetzner-64", which is the only question anyone asks afterwards. A local mutation commits with its audit row or not at all. No FK on actor_id and a denormalised actor_email, so the trail outlives an account wipe and still names a person.
One read is audited: fetching another tenant's task transcript, which is behind a click rather than loaded with the page. Auto-fetching would fill the log with accesses nobody chose to make and bury the ones somebody did.
Two things surfaced that nothing had ever shown before. Orphan runs — a microVM live on a host with no task behind it — are invisible from either side alone, because the fleet's run store is in-memory and dies with the process while a task row cannot see a run we never recorded. And FleetClient had been recording nothing to the debug bus, a gap only visible once something made fleet calls per pageview.
Also fixed in passing: routes/debug.ts's category allowlist omitted db and webhook while the panel had chips for both, so clicking either silently returned the unfiltered stream.
Left deliberately undone: fleet_hosts holds one snapshot per host, not a time series, so the incidents page reports counters cumulative since each host's last fleetd start rather than a rate. The page says so. A real trend needs either Prometheus scraping /metrics over the tailnet or an append-only samples table, and neither is worth it for one box.
The selfhosted provider merged in #22 was dead code: packages/backend/src/index.ts registered only PostHog Code and Claude Code, so getCloudProvider('selfhosted') returned null and no dispatch path could reach it. It is now registered behind FLEET_ENABLED.
Unregistered is a stronger off than a runtime branch. With the flag unset the provider is absent from the registry entirely — nothing behaves differently from before, and the failure mode of forgetting the flag is "the feature is missing", not "a task went somewhere unexpected". A workspace also needs fleet credentials configured before the provider accepts anything, so the flag alone changes nothing for any existing workspace. fleetRegistration.test.ts pins the gate, which is otherwise a single if in boot code that nothing covers — exactly the shape that gets dropped in a refactor and noticed in production.
The GitHub token wiring was already done — #22 fetches it fresh per dispatch via githubService.getAccessToken and sends it in the run payload. It goes backend → fleetd only; the fleet's credential proxy injects it host-side and it never enters the microVM. Checked that getAccessToken is synchronous, because an un-awaited promise there would have serialized as {} and failed as an auth error rather than a type error.
On the fleet side (Gilbert09/talyn-fleet), the credential proxy's /ghapi route had been attaching that token to any api.github.com path. Per-run socket isolation meant an agent could not reach another run's credentials, but it could spend its own on merging a PR, dispatching a workflow, editing repo settings, or installing a webhook or deploy key. It is now bounded twice — an allowlist of the endpoints the three task types need, and the repo the run was dispatched for — both checked before the credential is attached, so a refused call never spends the token.
That is the sixth bug this project has had of one shape: a check that passed every time anyone looked at it and was wrong anyway. Teardown that could not detect a leaked VM, wedge detection that killed healthy runs, a deploy check verifying a previous generation's image. talyn-fleet/docs/HANDOFF.md now leads with that pattern, because the instruction it implies — write the test that proves your check can fail — is the most transferable thing the project has produced.
apps/web went from the Session 76 placeholder to live at app.talyn.dev: panels ported, Vercel project created, deploy-app.yml green. The Vercel secrets were renamed along the way — *_WEB is the marketing site, *_APP is the application — because the two had been sharing VERCEL_PROJECT_ID and a mis-sequenced rename would have deployed the app over www.talyn.dev.
Most of the session's bugs were things that compiled, typechecked, and passed tests while being wrong in a browser, which is the failure mode a fork invites:
usePanelUrlSyncraced itself. Two effects, one commit, the same stale snapshot — clicking Merge Queue landed you on My PRs. Rewritten as a single effect that compares againstlast.currentto decide which side changed. (The mutation test for this hung vitest in an infinite loop, which is its own kind of proof.)window.openafter anawaitis a popup block. The GitHub App install flow now opens the tab synchronously and assignslocation.hrefonce the URL resolves.??doesn't fall through on empty strings, so a blank env var shippedapp_version: "web/". Build SHA resolution now filters on non-blank.- Dev CSP was missing
127.0.0.1(it hadlocalhost), so the app hung on the boot screen behind an opaque "Failed to fetch". - The nightly was broken by the
@talyn/clientsplit —nightly.ymlandpublish.ymlbuilt@talyn/sharedbut not the new package. The nightly caught it; the next stable release would have hit the same wall.
PostHog identity needed web-specific handling, twice. The desktop detects a fresh login by watching userId go null → set, which works because its OAuth runs in the system browser and the app never reloads. On web the redirect is a full page navigation that destroys exactly that transition, so logged_in was never captured at all — now a talyn:pending-login sessionStorage marker survives the hop. Separately, the identify effect also runs before auth resolves; treating "not known yet" as "signed out" meant posthog.reset() on every page load, churning the anonymous distinct_id and starting a fresh replay session each time.
"We can't reach the backend" was being reported as "GitHub OAuth isn't configured." useGithubConnection caught every failure — including there is no network — and recorded { configured: false }, which the banner renders as an alarming, actionable-looking, and entirely wrong instruction to go set GITHUB_CLIENT_ID. A transport failure means we never got an answer; it is not an answer. ApiNetworkError now short-circuits to an offline banner and the GitHub rows are suppressed rather than shown stale. Fixed on both clients.
The merge queue never told analytics it merged anything. Chasing "the PR merged tile shows nothing merged, which isn't true" found the tile was reporting its event honestly — pr_merged fired only from the desktop/web merge button, so every merge the queue performed, the product's headline feature, was invisible. 19 events in 30 days, most days literally 0. The executor now captures it, both paths carry a source property (merge_queue | manual), and the tile that hid the remaining signal under a shared linear axis (merges 1–8/day against fix runs up to 99/day) got dual Y axes. Desktop events also now carry client: 'desktop' to match the web app's client: 'web' — without it a client breakdown reads "web vs blank" and every desktop event stays unattributed.
trunk.io was switched off for posthog/posthog and the queue kept submitting PRs to a merge system that no longer existed. The cause was one line of ranking in repoMergeGate.ts: if (cached?.confirmed) return 'confirmed' sat above the TTL check, so a gate learned from an observed 405/403 was sticky for the life of the process. clearExternalMergeGate() existed for exactly this case but was unreachable — it only fires after a successful direct merge, and a confirmed gate never attempts one (decideCleanPath routes straight to submit_external). The only cure was a Railway redeploy.
Every reading now expires and must re-earn itself from a fresh probe, decaying one confidence level at a time: confirmed → (probe finds no rule) → suspected → (probe finds no rule) → null, with any observed refusal jumping straight back to confirmed. PROBE_TTL_MS / CONFIRMED_TTL_MS are 5min (down from a 1h TTL that confirmed ignored anyway); the submit-label cache keeps its own 1h constant, since repo labels are not what goes stale here.
The step down to suspected rather than straight to null is load-bearing. The probe hits /repos/{o}/{r}/rules/branches/{b}, which reports rulesets only — a classic protected branch, or a repo whose rulesets the App can't read, gates merges while probing clean. Dropping to null on that evidence would resume doomed merges every window. suspected is the right landing spot: it lets the queue try exactly one direct merge, which either lands the PR (firing the clear path that was previously unreachable) or re-confirms the gate. A probe that throws never decays anything — a failed call is not evidence.
Worst case is now ~10min to fully un-gate a branch with no restart, and usually faster: the first suspected evaluation merges and clears it outright.
Still manual after the gate clears: entries already parked in awaiting_external via the comment door land in blocked_manual ("the external merge queue never picked up the submit command") once the pickup grace expires, and R5c only un-sticks an external_gate block when the provider is seen actively holding the PR — which a switched-off queue never will. Those need a re-queue (SELECT * FROM merge_queue_entries WHERE status='awaiting_external' OR blocked_code='external_gate'). Related sharp edge, not fixed here: door 1 of the submit ladder is the provider's instruction comment on the PR, and those comments outlive the queue — so a suspected gate on a repo with stale trunk comments can still post a dead /trunk merge instead of falling through to the merge.
Tests: externalMergeQueue.test.ts gains a gate decay block on fake timers covering the full ladder, the still-gated hold, mid-decay re-confirmation, suspected→null on its own, and the failed-probe hold.
Scoping "what would it take to run Talyn in a browser" turned up a stronger reason to do it than the browser itself: publish.yml ran on macos-latest only, and marketing's download button resolved a .dmg and nothing else — so Windows and Linux users could not use Talyn at all. Four preparatory pushes; the apps/web fork itself is not started.
The paywall was opt-in and nobody knew. routes/tasks.ts keyed its exemption off a missing X-Talyn-Client-Version ("legacy client, can't render the upgrade flow"). The desktop renderer is the only sender in the repo, so packages/cli, packages/mcp-server, and plain curl bypassed both the 3-active-task limit and the 3-PR merge-queue cap. Silently: no error, no log, no metric, and no UpgradeModal, so the funnel read as "these users just don't convert". POST /pull-requests/:id/merge-queue was worse — its else branch called armQueue() with no gate at all, uncapping a subsystem that spends cloud-provider tokens per fix attempt. New services/billing/clientGate.ts is fail-closed: exempt only for a bare X.Y.Z below that gate's floor (0.2.3 tasks / 0.2.9 merge queue — the releases that shipped each paywall UI, so a v0.2.5 build correctly renders a task 402 but not a merge-queue one). Missing, dev, junk, and the namespaced web/<sha> form all enforce. Every exemption fires a billing_paywall_bypassed PostHog event, so the "remove once clients have aged out" note is finally measurable. The CLI and MCP server are now enforced — they surface the 402's human-readable message.
Windows + Linux desktop builds. electron-builder had declared win: [nsis] and linux: [AppImage] all along. Both publish.yml and nightly.yml now share one job shape: a version job resolves the version ONCE and fans it out (three legs each computing "next patch above the latest release" would race), then the macOS leg runs alone because it creates the Release and the tag, and only then does a windows-latest/ubuntu-latest matrix upload into it. The chaining is load-bearing — parallel races three electron-builder processes to create the same release, and needs: means a Windows failure can't take down a macOS release that already published. Nightly gets the other platforms too: the channel picker maps to allowPrerelease, so a nightly-channel Windows user with no Windows asset gets a broken updater, not a skipped one. Windows ships unsigned (SmartScreen warns) pending an EV cert. Marketing's DownloadButton sniffs the OS after mount — never during render, the server has no navigator — and CTA copy carries a {platform} token so the voice stays in lib/content.ts while the OS name stays a runtime fact.
packages/client. lib/api.ts was 1,132 lines of backend contract living inside the Electron app; a second front end would have forked it, and two copies drift. Moved to @talyn/client, with hosts calling configureApiClient({ baseUrl, clientVersion, getAccessToken, recoverSession }) — the refresh-stampede dedupe stays in the package (transport concern), the "is this session really dead?" judgement stays with the host (only it knows its auth provider). The desktop's lib/api.ts is now ~50 lines of glue plus export * from '@talyn/client', so all ~40 importers were untouched and git scored it as a rename. Also fixed the WS for backgrounded tabs, which a minimised desktop window needs too: bindLifecycle gained visibilitychange + pageshow, and waking on an apparently-OPEN socket now pings instead of returning early (after a freeze, "open" is exactly what a half-open socket looks like). Browsers throttle hidden-tab setInterval to ~1/min and freeze it for a bfcached page, so the 25s heartbeat cannot keep a background socket honest — accept the drop, make the return fast.
Browser-origin surface (inert until app.talyn.dev exists). services/originPolicy.ts extracts the CORS/WS allowlist out of index.ts so it's testable without booting the server — exact string match, never a pattern, since a prefix rule is how https://app.talyn.dev.evil.com gets in. A rejected origin now denies by omitting the header (cb(null, false)) rather than throwing a 500 that read as "the backend is broken"; credentials: false (Bearer-only API — makes CSRF-immunity structural); maxAge: 86400, because the non-safelisted client-version header preflights every request. The null-origin concession for the packaged renderer's file:// handshake is forgeable by any page via a sandboxed iframe — harmless while WS auth is a first-frame Bearer JWT, a live cross-site hijack the day anything moves to cookies — so it now sits behind TALYN_ALLOW_NULL_ORIGIN_WS. services/webApp.ts owns WEB_APP_URL (env-only, boot-validated, https-or-localhost); webAppUrl() refuses any path that isn't single-slash-relative, because it is the GitHub App callback's redirect target and an open redirect there turns a login flow into a phishing hop. That callback now ends per-client — browser → 302 to /settings?github=…, desktop → the close-this-tab page — decided by the Origin recorded server-side when the state was minted, never by a request parameter.
Also added a per-USER rate limit after requireAuth. IP-keying alone gets both directions wrong once a browser client exists: an office behind one NAT egress shares a bucket it didn't individually fill, while a runaway user on a home connection never touches it. The per-IP ceiling was deliberately not raised — it bounds unauthenticated work, and the expensive path it guards is the legacy HS256 branch in verifyTokenAndGetUser, which makes an outbound Supabase call per attempt.
The spike, and what it changed. Before forking anything, a throwaway Vite app ran the real @talyn/client (aliased to its source) in Chrome against the local stack. Three results, two of which contradicted the plan:
- OAuth without a popup: PASS. Full-page
signInWithOAuth(noskipBrowserRedirect) +detectSessionInUrl: true→provider: github, PKCE verifier in localStorage,window.opener === null,?code=consumed and stripped. The desktop'sopenExternal(data.url)fires after twoawaits, so itswindow.openfallback has lost user activation and is popup-blocked — silently, on the sign-in screen. defineofprocess.env.*does NOT work in Vite dev. Vite's entries are "defined as globals during dev and statically replaced during build", so the plan's "mirror webpack's EnvironmentPlugin" approach serves the dev browser an unsubstituted expression that throws on the missingprocess.apps/webusesimport.meta.env.VITE_*.- Background-tab WebSocket: PASS, ~13 minutes hidden, zero drops, client and server agreeing (no reconnect logged, backend's last WS event is
connected, oneESTABLISHEDsocket throughout). The feared false-positive — a throttled heartbeat trippingawaitingPongand forcing reconnect churn — did not occur; a throttled tick still sends its ping and still clears on the pong. The recovery-on-wake path added topackages/clientwas therefore not exercised; it stays in as defence for sleep/bfcache but is unproven.
apps/web scaffolded. Workspace member (unlike apps/marketing), Vite + React 19, BrowserRouter with real panel URLs, browser PKCE AuthProvider, /login + /auth/callback, CSP as a vercel.json response header, deploy-web.yml (guarded to skip until VERCEL_PROJECT_ID_WEB exists — a different Vercel project from marketing's, or a push would overwrite www.talyn.dev). routes/Shell.tsx is a placeholder proving session → REST → WS end-to-end; the panels themselves are still to be ported. Per Tom's call this is a deliberate fork of the desktop renderer — features get built twice — but the backend contract is not forked: both import @talyn/client.
packages/client now ships dual-format. Rollup can't statically see the re-exports tsc's CJS output emits as Object.defineProperty(exports, …) getters (Vite: "configureApiClient is not exported"), while the desktop's jest suite still needs CJS — so dist/cjs + dist/esm behind an exports map, with a one-key package.json in each so Node doesn't misparse the ESM output.
Still open: porting the panels (~18k LOC), and creating the Vercel project. See docs/ROADMAP.md.
Same day as Session 74, from live use: seven PostHog PRs sat in the merge queue reading "Needs you — Talyn posted the merge queue's own submit command and the queue never picked it up", while trunk was demonstrably testing every one of them (#74552's /trunk merge from talyn-app[bot] even carried a 👍 from trunk).
Root cause: the label channel is not the signal. Session 74 read trunk's state exclusively from labels (trunk-queued / trunk-testing / …), which are optional in trunk's configuration. On posthog/posthog:
- #74552 ran a full trunk test cycle with no queue label ever applied (its whole
labeledtimeline is onestamphog), and 6 sibling PRs were the same. - PRs merged hours earlier still carried a stale
trunk-testing.
So "no label" was read as "trunk ignored our comment", and decide blocked the entry (blocked_manual/external_gate) after the 10-minute grace window — sticky until a push or a requeue.
The reliable channel is trunk's own PR comment, which it keeps as ONE comment and edits in place through the lifecycle. Captured verbatim off 100 recent PRs: instruction + submit checkbox → ✨ Submitted to Merge by @x → ⏳ Waiting to start tests → 🧪 Running tests on this pull request (testing on PR #x) → 👍 will be merged soon because tests have passed → 😎 Merged successfully, with 🚫 removed from the merge queue because it was pushed to by @x, ❌ could not start testing because there was a merge conflict, and ⚠️ The required check … has failed for the failure paths. Crucially, every edit is an issue_comment webhook — an event Talyn already processes — so the state arrives free and in real time.
- Parser —
packages/shared/src/externalMergeQueue.ts:externalQueueStatusFromComment(s)maps those bodies toExternalQueueState, with the submit checkbox (- [x]/- [ ]between trunk'sStart/End PR Submit Checkboxmarkers) as the fallback when there's no status line. Two new states:not_submitted(the box is untouched — trunk genuinely does NOT have the PR, the only honest basis for the "never picked it up" block) andrejected(trunk says it cannot merge this PR — e.g. a stacked PR — which no fix run or resubmit can move, so it blocks manually).ExternalQueueStatus.labelbecamesource+evidence, so a tooltip can quote trunk's own sentence. Identification is deliberately narrow: trunk's other comment (Test Analytics) is by the same bot, on the same host, and full of the word "failed" — only the/merge-queue/link path and the markers tell them apart. - State cache —
services/externalQueueState.ts: webhook-fed (webhookWorkerhands everyissue_commentbody to it) with a REST backstop for a cold cache. The merge-queue executor asks for it only when a gate exists AND the entry's fate depends on the answer, with a staleness bound matched to how fast the answer can change (externalStateMaxAge): 60s while waiting for trunk to say anything at all, 10min once it IS working the PR (that's purely a missed-delivery backstop). In practice this costs ~0 extra GitHub calls. decide— the comment channel now outranks labels everywhere (externalQueueOf(pr, ctx)). "Not picked up" requires the provider saying so past the grace window, rather than the absence of a label. New R5c: an entry blocked on the external queue that is now observed being held by it (isExternalQueueHolding) goes straight back toawaiting_external— which is what un-stuck the seven live PRs on deploy, with no requeue.- Persisted state —
merge_queue_entries.external_state/external_state_at(migration0037), written whenever the provider's state MOVES. Drives the entry timeline, survives a restart, and reaches the desktop asmergeQueue.external.stateso the queue cell renders "Queue: testing" on a PR with no labels at all.PRStatusPillandisReadyToMergeprefer it over labels too. - Latent bug found by the same corpus:
externalQueueInstructionFromCommentsrequired trunk's<!-- Trunk Merge -->marker, which trunk DROPS once it rewrites the comment — including in the post-ejection body that re-offers/trunk merge. Door 1 was therefore unavailable on exactly the resubmit path the queue exists for; it now identifies the comment structurally.
Tests: 40 new cases pinned to the verbatim trunk bodies (externalMergeQueue.test.ts), externalQueueState.test.ts (cache/backstop/staleness policy), 11 new decide.test.ts cases for the comment channel + self-heal, and a webhookWorker.test.ts case proving a comment edit populates the cache with no GitHub call.
posthog/posthog moved master behind Trunk Merge Queue. A repo-level ruleset ("Trunk merge", active since 2026-07-22, enforcement flipped 07-28) adds update/creation/deletion/non_fast_forward rules to the default branch and exempts only three GitHub Apps (trunk-io is 120106); current_user_can_bypass: never. So Talyn's merge PUT 405s with "Cannot update this protected ref" — for every PR, forever.
Session 71's external_gate terminal block (added a week earlier) stopped the doomed retry loop but left every queued PostHog PR parked in blocked_manual. This session makes the queue do the valuable half: get the PR green, then hand it to the system that owns the branch, track it there, and take it back if it's ejected.
How PostHog's PRs actually reach trunk (verified off PR #74353's timeline): the author enables GitHub's native auto-merge, and ~30s later trunk-io[bot] labels the PR trunk-not-ready → trunk-queued → trunk-testing → trunk-tests-passed → merges it (42 min end to end). Labels are the only channel trunk reports on; there is no API.
- Detect the gate —
services/repoMergeGate.ts.'suspected'from a cheap REST branch-rules probe (GET /repos/{o}/{r}/rules/branches/{b}, no admin scope, no GraphQL points, cached 1h): it sees anupdaterule but not bypass actors, so it can't tell "gated" from "we're exempt".'confirmed'is learned from an observed 405 and is sticky for the process (peer ofrepoSigning.ts'smarkSigningRequired). Only a confirmed gate skips the direct merge; a suspected one still tries it once and lets the answer settle it. A merge that succeeds clears the mark. - Submit instead of merge —
services/externalQueueSubmit.ts, shared by the pipeline and the desktop Merge button. Door 1: arm GitHub auto-merge (what PostHog humans do; no new App permission). Door 2: apply the repo's submit label (trunk-merge-queue-submit/trunk-merge, only if the repo defines it). Door 2 is not optional — GitHub refuses to arm auto-merge on a PR that is already immediately mergeable ("clean status"), which is exactly the state a gated PR reaches once its checks pass, so without it the readiest PRs would be the unsubmittable ones. Needs the App'sissues: write. - Track + recover — new entry status
awaiting_external(+submit_attempts/external_submit_viacolumns, migration0035).decideR5b waits while trunk reports a live state, still remediates a settled blocker underneath it (trunk holds a conflicting PR at "not ready" forever), and on ejection (trunk-failed/trunk-pending-failure) requeues → fixes → resubmits, bounded by a per-head submit budget that self-heals on a new push.trunk-cancelledis deliberately terminal (blocked/external_queue_rejected) — someone pulled the PR out on purpose. A Talyn-armed auto-merge is always disarmed on the way into a blocked state, so a rejected PR can't quietly re-enter the queue. - No double queueing — a gated (repo, base) group is always evaluated eagerly, whatever the workspace's
mergeQueueMode: trunk batches and orders merges itself, so serializing behind our own head would add its whole ~40min cycle to every PR in the group. - Labels are now tracked — added to the PR GraphQL selection,
PRMergeableSummary, andsummaryToJsonb; thepull_request.labeled/unlabeledwebhook patches them straight from the payload (no GitHub fetch) and emitspr:snapshot, which is what drives the queue's reactivity. Shared vocabulary + mapping live inpackages/shared/src/externalMergeQueue.ts. - Desktop — the Merge button submits and toasts "Submitted … to the merge queue" (the route answers
{ merged: false, submitted: true }); the queue cell renders "Queue: testing" etc.;PRStatusPillshows the provider's state on any PR carrying its labels, ranked above every open-state verdict ("Ready" is a lie on a branch only trunk can merge).
Tests: externalMergeQueue.test.ts (label vocabulary incl. (bisection) variants, gate probe, submit ladder), 30 new decide.test.ts cases, 8 pipeline cases in mergeQueue/evaluator.test.ts, rewritten webhook label cases. Not ported to the dormant v1 processor — it stays the rollback target as-is, so a settings.merge_queue_engine = 'v1' rollback also reverts to "can't merge PostHog PRs".
Two corrections the same day, both from live use:
-
A gated branch reports
BLOCKEDfor every PR. Queueing a fully-ready PostHog PR started a cloud fix run instead of submitting it. GitHub reportsmergeStateStatus = BLOCKEDfor every PR on a branch whose ruleset forbids ref updates — all 20 most-recently-updated open PRs on posthog/posthog came back MERGEABLE + BLOCKED, approved ones included — andqueueBlocked()counted that as a fixable blocker, so decide never reached the submit path.decidenow uses a gate-awarequeueBlockedFor(pr, ctx): with a gate, a bare BLOCKED is the gate. Same root cause hid the desktop Merge button and emptied the "Ready to merge" bucket on that repo; both now accept "held only by branch protection". -
Auto-merge is NOT trunk's submit door (the original design's primary). The inference came from #74353's timeline — auto-merge armed at 20:45:35,
trunk-not-ready30s later — but trunk's own comment on every PR says: "To merge this pull request, check the box to the left or comment/trunk mergebelow." Comment edits don't appear in a timeline, so what actually happened is the author ticked trunk's checkbox; the auto-merge correlation was coincidence. Confirmed live on #74354: Talyn armed auto-merge, trunk ignored it entirely. The submit ladder is now comment → label → auto-merge: door 1 reads the provider's own instruction comment off the PR (<!-- Trunk Merge -->+ the offered command) and posts that command; auto-merge drops to last, where it still serves GitHub's native queue. Since a posted command leaves nothing re-readable on GitHub,external_submitted_at(migration0036) + a 10-minute grace window distinguishes "trunk hasn't labelled it yet" from "trunk ignored us" — the latter blocks with an actionable reason rather than re-posting the command. Open question: whether trunk accepts/trunk mergefrom a GitHub App at all; if it doesn't, the block reason says so and a human ticks the box.
A PostHog Code run that goes idle waiting on CI/review sits in in_progress on PostHog's side forever, so maybeFinalizeIdle (services/posthogCode/poller.ts) optimistically completes the local task after IDLE_FINALIZE_MS of no updated_at movement. But when the wait clears the run resumes — and the local task was already Done, never to be re-polled (the generic cloud poller only loads in_progress).
Fix: an idle-finalized task is now a revival candidate.
maybeFinalizeIdlestamps a genericmetadata.reviveEligible: truewhen it optimistically completes an idle (remote-still-in_progress) run.- The generic cloud poller (
cloudProviders/poller.ts) now loadsin_progresstasks pluscompletedtasks carryingreviveEligiblewhosecompletedAtis within a 24hREVIVE_WINDOW_MS(ceiling for a legitimate suspension; past that the remote sandbox is abandoned). The jsonb-containment flag keeps this set tiny — genuinely-completed tasks (remote reached a terminal state) never carry it.CloudTaskRowgainedstatus+completedAt. - PostHog
reconcilegained amaybeRevivebranch (throttled toIDLE_RECHECK_MSper task): if the remote run is non-terminal and itsupdated_athas advanced pastcompletedAt(idle keepalives don't bumpupdated_at, so a still-idle run never trips it → no revive/finalize ping-pong), the task is flipped back toin_progress(clearingresult/completedAt/reviveEligible) and falls through to normal reconcile. Once the remote run is genuinely terminal, the flag is cleared so it stops being a candidate.
Claude Code needs none of this — its pause_turn is already kept non-terminal, so a paused session stays in_progress. Tests: posthogCodePollerRevive.test.ts (revive/idle/terminal/throttle) + a revival-candidate WHERE-clause case in cloudPollerEgress.test.ts.
Live incident: the queue dispatched thousands of duplicate "Get <ref> mergeable (merge queue)" pr_response runs against posthog/posthog (e.g. #71167 got runs at 1h → 3× at 44m → 24m). Two compounding bugs in v2:
-
Bug 1 — the fix budget reset on the queue's OWN commits (the re-fire loop). A "get mergeable" fix run pushes commits, changing the head SHA.
decideR2 treated any head-SHA change as "fresh external code → fresh budgets" and zeroedfixAttempts(Session 71 item A's "reset on every push" self-healing mechanic). So for any PR the agent can't actually land (needs review, unfixable CI),MAX_ATTEMPTScould never bite: fix → push → new head → reset → fix … forever. Fix: R2 now distinguishes head changes authored by an in-flight, unaccounted fix run (fixTaskId !== null && !fixTaskAccounted) from genuine external pushes. Our own pushes take the newadopt_headaction (advance the head pointer, keep the budget so R8 still accounts the attempt); only external pushesreset_budgets. The cap bites afterMAX_ATTEMPTSreal runs; a human push after that still self-heals. -
Bug 2 — the task was created BEFORE the entry was claimed (the concurrent burst).
fireFixRuncalledcreateCloudTaskthen did the CAS that setsfixTaskId; the only dedup guard isfixTaskId, unset until after the task exists. With no per-group lock (removed for pool-starvation reasons —evaluator.tscomment), a webhook burst / cross-replica overlap ran N evaluations that all readfixTaskId=null, all dispatched, and only one won the CAS — the rest were live orphans (the 3-at-44m). The evaluator comment claiming "fix-run dispatch dedupes via the shared task guards" was false. Fix: oncasLost,fireFixRunnow cancels its just-created task viacancelUndispatchedFixTask(markscancelledwhile stillqueued/pending). SincecreateCloudTaskinsertsqueuedand the scheduler dispatches async on its next tick, the loser's cancel lands before dispatch → the vendor run never starts. Net: exactly one active fix run per fire, regardless of concurrency. -
Containment (ops):
packages/backend/scripts/cancel-runaway-merge-tasks.ts— dry-run by default (EXECUTE=1to mutate), scopes strictly to activepr_responsetasks titledGet … mergeable (merge queue), cancels each likePOST /tasks/:id/stop(best-effort remote PostHog cancel + mark cancelled). Emptying the v2 queue (UPDATE merge_queue_entries SET status='removed' WHERE status NOT IN ('merged','removed')) stops all firing on its own — entries are authoritative;pull_requests.merge_queuedis only a downstream mirror. Rollingmerge_queue_engineback tov1is NOT a safe stop (v1 has its own fire-forever history; there's no "off" value). -
Tests:
decide.test.ts— new "a head pushed by our OWN fix run does NOT reset budgets" block (adopt-while-active, account-at-cap, still-reset-on-external-push). All 130 mergeQueue tests green.
Follow-up — Bug 2 upgraded to claim-first (middle-ground). fireFixRun now CLAIMS the entry via CAS (status→fixing, fixTaskId=null, event fix_run_claimed) before creating the cloud task, then creates and LINKS it in a second CAS (fix_run_fired). N concurrent cross-replica evaluations racing at the same entry version collapse to exactly one claim — the losers bail before createCloudTask, so no duplicate is created (vs the previous create-then-cancel). A TaskLimitError rolls the claim back to queued (burns nothing). A crash between claim and link leaves the entry fixing+null; the existing 120s reconciler sweep re-evaluates it and decide (unchanged — reads null fixTaskId as no active run) re-fires — natural recovery, no wedge, no migration. cancelUndispatchedFixTask is retained as the backstop for the rare sub-second late-eval that re-claims between our claim and link. Chosen over "full" claim-first (a decide hold on the claimed-but-unlinked window) because that needs a new timestamp column — touchEvaluated resets lastEvaluatedAt on held evals — and has a worse failure mode. Tests: evaluator.test.ts — claim-before-create ordering, task-limit revert, half-claimed crash recovery (133 mergeQueue tests green).
Full rebuild of the merge queue, replacing the 10s-poll processor (a 1,217-line incident-hardened state machine deciding off up-to-90s-stale cached summaries, with terminal blocked states and a jsonb state blob) with an event-driven, self-healing pipeline. Shipped as six deploys (A–F below); the audit + design that drove it started from the pain map in the git history (rate-limit freezes d4f7f898/9530633228, the June wedge 120bbbda9c, fix-run churn revert 034c3dbb, draft jam 52ba5dc9).
- A — pure decision core (
services/mergeQueue/{types,decide}.ts+ 84-case decision table): everyprocessHeadbranch is an explicit rule over(entry, PR snapshot, ctx) → actions + verdict, zero I/O. New semantics: per-headSha budgets that reset on every push (the self-healing mechanic; safe from the old cap-evasion trap because a sha change is monotonic),blocked_manualreserved for App-permission refusals,awaiting_reviewinstead of doomed fix runs for review-gated PRs,update_branch(one REST call) before a paid fix run for BEHIND heads. - B — schema (migration
0031):merge_queue_entries(typed columns, CASversion, partial-unique active entry per PR, terminal rows kept 30 days) +merge_queue_events(per-entry audit timeline) +settings.merge_queue_engineflag + backfill from the blobs; route dual-writes membership. - C — pipeline (
mergeQueue/{store,executor,evaluator,triggers,reconciler,legacy}.ts): newpr:snapshot/pr:checksdomain events from prCache upserts + the check-count fast lane (+task:status) trigger per-(repo,base) group evaluations — trigger-coalesced, per-group advisory lock, 45s timeout, no global tick/lock/TickGuard (a hung call stalls one group, never the queue). Executor: verify-live-then-merge, verify-merged recovery, per-head-memoized signing probe, bounded check re-runs, TaskLimit defers burn nothing, legacy WS/blob mirroring for old desktop builds. - D — cutover (migration
0032): re-syncs entries from the blobs, flips the flag tov2; the old processor re-reads it per tick and stands down within ~10s. v1 code stays in place as the rollback target (UPDATE settings SET value='"v1"' WHERE key='merge_queue_engine'). - E — GitHub native auto-merge hybrid (
githubAutoMerge.ts): the group head, clean-but-awaiting-CI, getsenablePullRequestAutoMerge(expectedHeadOid-pinned; capability probed per repo, 1h cache + sticky learn-from-refusal) — GitHub merges the instant checks pass. Invariants: at most one armed entry per (repo,base); any transition into blocked disarms a Talyn-armed auto-merge first; dequeue disarms synchronously with apendingDisarmreconciler retry; user-armed auto-merges are adopted, never disarmed. PlusgithubService.updatePullRequestBranchfor BEHIND heads. - F — desktop: QueueCell v2 vocabulary (Auto-merge armed / Waiting for CI / Waiting for review / Fixing n/3 / Blocked-self-healing vs Needs-you), detail-sheet "Merge queue" section (budgets scoped to head, Requeue button, audit timeline via
GET /pull-requests/:id/merge-queue/timeline), REST list decorated with the v2 payload. - Deferred — Push G (cleanup, after soak): delete
mergeQueueProcessor.ts+mergeQueueBroadcast.ts+ the legacy suite, dropmerge_queue_state(thenmerge_queued*) columns in0033, remove the engine flag, switchcountQueuedPrsQueryto the entries table, update CLAUDE.md's egress examples. Verify during soak (flagged live-API behaviors implemented defensively): the exactexpectedHeadOid-mismatch and "clean status" error strings,auto_merge_disabledpayload contents, arm survival across bot-authored fix-run pushes, behavior on GitHub-merge-queue-protected branches, update-branch commits vs required-signatures rulesets. Watch the Debug panel'smerge_queue_reconcilepoller +merge_queueevent stream and themerge_queue_eventstable.
- Rule: free owners can hold at most 3 PRs in the merge queue at once (counted like the task limit: across every workspace they own; only
state='open'rows withmergeQueued=true). Unlimited/comped owners uncapped. Enforcement obeys the samePOLAR_*kill switch and the same legacy-client bypass (noX-Talyn-Client-Versionheader → not enforced). - Backend (
services/billing/entitlements.ts): the task-gate lock choreography was factored into a sharedwithFreePlanGate(per-ownerpg_advisory_xact_lock, ownerScope-transaction vs pool-mutex vs pglite-skip — unchanged semantics) now backing bothwithTaskLimitGateand the newwithMergeQueueLimitGate.countQueuedPrsQueryis exported unexecuted for the egress guard (pure count, never shipslastSummary). Gate wired intoPOST /pull-requests/:id/merge-queue(enable only; dequeues and re-arms of an already-queued PR are exempt viaexcludePrId).MergeQueueLimitError→ 402code:'merge_queue_limit_reached'in the sharedapiErrorHandler.GET /billing/statusgainedqueuedPrs+mergeQueueLimit. Tests:routes/mergeQueueLimit.test.ts,billingEgress.test.ts. - Desktop:
maybeHandleTaskLimit→maybeHandleBillingLimit(both 402 codes → UpgradeModal); the merge-queue toggle rolls back its optimistic patch and opens the modal instead of a raw error toast; UpgradeModal pitch now names whichever cap was hit; Settings → Billing shows two free-plan usage meters (Active tasks, Merge queue) via the extractedUsageMeter. - Marketing: pricing tiers + FAQ on talyn.dev now say "3 running tasks and 3 queued PRs" / "Unlimited PRs in the merge queue".
Session 69 — Prod incident: mass logout (auth outage read as invalid tokens) → local JWT verification
- Incident (2026-07-07 19:33–21:37 UTC): every active desktop user was force-logged-out. Chain: Supabase's
/auth/v1/userhung (~19.5s) →requireAuth'ssupabase.auth.getUser(token)failed → backend answered 401 "Invalid or expired token" for perfectly valid sessions → desktoprequest()treated any 401 as "session unrecoverable" and ransignOut({scope:'local'}). Evidence: Railway HTTP logs (22×401 across 5 IPs/app versions, half taking 19.4–19.8s — a 401 should take ms) lined up to the second with PostHoglogged_outevents. No deploy in the window, no 5xx, no Supabase status-page incident (their Jul 6 "Americas 500s" major incident likely explains the previous day's logouts). A separate overnight logout (Jul 7 02:35, no backend 401s at all) points at the refresh-token rotation race on app restart — mitigated but not fully solved here. - Backend — local JWT verification (
middleware/auth.ts): access tokens are now verified locally withjoseagainst the project's public ES256 JWKS (/auth/v1/.well-known/jwks.json, cached in memory bycreateRemoteJWKSet) — zero per-request network dependency on Supabase, and a whole class of incident gone. Legacy HS256 tokens still round-trip togetUser, but with a 5s timeout. NOTE:joseis pinned to v5 — v6 is ESM-only + needs global WebCrypto (Node 20+); v5 ships CJS builds and works on Node 18 dev machines. - Backend — 401 vs 503:
AuthErrorgained an'unavailable'code. "Couldn't check the token" (JWKS fetch failure/timeout, Supabase network error/5xx/hang) now maps to 503 +code:'auth_unavailable'(loudly logged — this path was invisible during the incident); only an actual token rejection 401s. The WS handshake closes with 1013 (try again later) instead of 4401 when verification is unavailable. Tests:authMiddleware.test.ts(ES256 valid/expired/wrong-key/wrong-claims, JWKS-down→503, HS256 4xx→401 vs network/5xx/hang→503). - Desktop — 401 no longer nukes the session (
lib/api.ts): on a 401,request()runs a dedupedrefreshSession()and replays the request once with the fresh token. Sign-out happens ONLY when the auth server explicitly rejects the refresh token (4xx); network failures/5xx keep the session and surface the request error. Tests:api401Recovery.test.ts. - Desktop —
logged_outreason instrumentation (lib/logoutReason.ts): the incident'slogged_outevents carried no properties, so forced vs manual logouts were indistinguishable. Sign-out call sites now tag a reason (manual,account_wiped,api_401_refresh_rejected; untagged =supabase_auto, i.e. the Supabase client cleared the session itself — the signature of the refresh-rotation race) whichAnalyticsattaches to the event. - Follow-up candidates: persist rotated refresh tokens more aggressively around app quit/update-restart (the
supabase_autoreason will now show how often that race actually fires); desktop toast/banner forauth_unavailable503s.
- Model: free plan = max 3 active tasks (
pending|queued|in_progress) per owner across ALL their workspaces; Unlimited = $15/mo or $150/yr. Provider is Polar.sh (merchant of record — handles global VAT; chosen over Paddle for DX/instant signup, accepting seed-stage risk). Comping =plan_overridecolumn set via SQL (UPDATE users SET plan_override='unlimited' WHERE email='…') — never touched by webhooks, wins over the webhook-drivenplan. - Entitlement seam (
services/billing/entitlements.ts):resolveEntitlement(override → plan),countActiveTasks(pure count, egress-guarded bybillingEgress.test.ts),withTaskLimitGate— per-ownerpg_advisory_xact_lockon the free path only; on routes it rides theownerScopetransaction so the lock holds until the insert commits; watchers usewithBlockingAdvisoryLock; pglite skips the lock (guardCrossReplicaprecedent). Gate lives increateCloudTask(all creation paths incl. watchers +/pull-requests/:id/fix), plusassertCanActivateTaskon retry/start/PATCH-to-active (the PATCH status path was previously an ungated re-activation hole).TaskLimitError→ 402 +code:'task_limit_reached'in the now-exportedapiErrorHandler. Merge queue holds (waiting, no attempt burned, no blocked badge); auto-keep skips its tick. - Polar module (
services/billing/polar.ts+webhook.ts): checkout viaexternalCustomerId=userId(comes back on every webhook ascustomer.external_id), hosted customer portal, best-effort revoke onDELETE /users/me. Webhook at/api/v1/webhooks/polar(raw-body, pre-express.json): idempotent viabilling_eventsPK insert, order-safe via thewebhook-timestampwatermark per subscription id, grants onactive|trialing|past_due, revokes onsubscription.revoked/terminal statuses, thenemitSubscriptionUpdated(per-user WS). Schema: migration0030_billing.sql(users billing columns +billing_events, RLS enabled/no grant). - Config: all-or-nothing
POLAR_*env group invalidateEnv(POLAR_ACCESS_TOKEN,POLAR_WEBHOOK_SECRET,POLAR_ENVIRONMENT,POLAR_PRODUCT_ID_MONTHLY/ANNUAL; optionalPOLAR_SUCCESS_URL). Env absent → enforcement OFF (loud boot warning; deliberate — a paywall nobody can pay would brick dev/self-hosted; doubles as the prod kill switch). Everything shipped dark; flip = config only. - Desktop: typed
ApiError(status+code) fromrequest();stores/billing.ts(status snapshot refreshed on mount/focus/reconnect/WS push + a 3s×2min post-checkout poll burst;maybeHandleTaskLimitopens the globalUpgradeModalon the 402); Settings → Billing section (free usage meter n/3, comped/past_due/cancel-at-period-end states, portal button); PR-row task button gets an at-limit tooltip but stays enabled (server is the authority). - Flip checklist (config only, AFTER a desktop release ships so old clients don't see raw 402 text): Polar production org + $15/mo + $150/yr products, Railway
POLAR_*vars, registerhttps://prod.talyn.dev/api/v1/webhooks/polar(subscription.* events), optional talyn.dev success page. Verify on the Polar sandbox first (checkout → webhook → WS). Tests:billingEntitlements,billingEgress,billingWebhook,routes/billing,routes/tasksCreateLimit, + merge-queue/auto-keep limit cases.
- Incident (2026-07-06 08:43–08:57 UTC, repeat of 2026-07-04 13:45–14:06): desktop users hit the "Talyn can't reach its server" screen. Root cause chain:
ownerScopeholds an open transaction for the life of every authenticated request → handlers awaiting GitHub calls sit idle-in-transaction, pinning Supavisor (transaction-pooler) backend connections → pool exhausts under webhook-hour load (~15 GitHub webhooks/s) → every query queues intoECHECKOUTTIMEOUTafter 60s FATALs → WS auth timeouts, poll ticks wedged 5–6 min,/healthDB probe (3s bound) 503s continuously.statement_timeoutnever fired — no statement was running. Recovery required a manual Railway restart (dropping the process's connections freed the pinned backends): Railway'shealthcheckTimeoutonly gates deploy cutover; it does NOT healthcheck running deploys. - Fixes: (1)
idle_in_transaction_session_timeout: 30_000besidestatement_timeoutindb/client.ts— kills the pinned sessions instead of wedging the service; (2) newservices/dbWatchdog.ts— boundedselect 1every 15s, after 8 consecutive failures (~2 min)process.exit(1)so Railway's ON_FAILURE policy restarts us (registered on the debugBus poller registry; tests indbWatchdog.test.ts); (3)restartPolicyMaxRetries5 → 25 (watchdog exits are deliberate and the retry budget is per-deployment-lifetime). - Ops follow-ups:
WEBHOOK_TRACE=1was live in prod and blowing Railway's 500 logs/s cap (logs dropped mid-incident) — flip to 0. Pin the same idle-in-transaction timeout role-level in Supabase (ALTER ROLE) as defense-in-depth (startup params may not survive the pooler), and review poolerpool_sizevs the clientmax: 20. Still open from S66: uptime alerting on/health.
- Repo renamed
Gilbert09/owl→Gilbert09/talyn(GitHub App unaffected; 301 redirects keep old clones + shipped auto-updaters working — never reuse theowlname). All references, workflow guards, and the electron-builder publish target updated the same push. - Stable/nightly update channels: nightlies stay pre-releases; tagged builds are full releases. New in-app picker (Settings → About, persisted in userData, default stable); the marketing DownloadButton prefers
/releases/latest. Fixed publish.yml to bake the tag version into the build (was shipping the static 0.1.0 regardless of tag) and addedworkflow_dispatchso a stable release is one click in Actions (version optional — auto-next-patch above the highest release). v0.2.0 shipped as the first stable release (dual-arch, notarized, verified on the feed). - README launch pass (download pointer, live providers, GitHub App, skills; task-types + daemon/SSH history removed) and docs purge: deleted AUTONOMOUS_BUILD / CONTINUOUS_BUILD(-ROADMAP) / DAEMON_EVERYWHERE / SUPACODE_COMPARISON / bootstrap-vm.sh; ARCHITECTURE.md rewritten for the cloud-only system (old decisions kept, marked superseded).
- Contact email removed site-wide + desktop (Help → "Report an Issue", crash dialog) — support channel is GitHub issues. Site email capture remains only as non-Mac "get notified".
- Merge-queue follow-ups: fix button enabled for failing non-required checks (
prHasFixableIssues, manual-only — auto paths unchanged); WS disconnects only reach error tracking after 3 failed reconnects; PR-row actions cleared of the scrollbar; Copy list indents stacked PRs (nested markdown/HTML). - Notable: signups were always open (
TALYN_ALLOWED_EMAILSis an unset optional gate;TALYN_ADMIN_EMAILSonly grants admin).EnvironmentTypein shared types flagged stale (claude_codemissing, deadlocal/remotemembers) — cleanup candidate. Still open: uptime alerting on/health.
Full launch-readiness audit (5 parallel audit agents: security, backend scaling, desktop UX, marketing site, docs/gaps), then 24 fix commits landed across three parallel streams. Highlights:
- Marketing (6 commits): baked the publishable PostHog key into
lib/analytics.ts— the Vercel env never hadNEXT_PUBLIC_POSTHOG_KEY, sowaitlist_signupevents were silently dropped in prod; removed the visible "Template notice" banners from privacy/terms and set governing law to England and Wales; canonical host fixed towww.talyn.dev+robots.ts/sitemap.ts/canonicals; footer Support mailto; PNG/apple-touch favicon fallbacks; FinalCta copy intocontent.ts. - Desktop (8 commits): de-boilerplated the menu (was "About ElectronReact"; Help now talyn.dev + support mailto + Check for Updates); backend-unreachable screen auto-retries with backoff (dev-only
npm run devhint); top-levelErrorBoundary+render-process-gonereload with crash-loop guard;will-navigate/will-redirectguards + http(s)-onlyopenExternalGuardedon all external-URL paths; analytics/session-replay opt-out toggle (Settings → Account → Privacy; replay respects it at init); "Get a key ↗" links + scope notes on PostHog/Anthropic credential forms (onboarding + Settings); account-wipe tool gated to dev builds; ipc-example boilerplate deleted end-to-end. - Backend (10 commits): process-level
unhandledRejection/uncaughtExceptionhandlers, arity-4 error middleware (was dead code),asyncHandler/wrapAsyncRouteson every router;httpTimeout.tsfetchWithTimeout on both cloud clients + 120s SSE idle timeout +TickGuardon taskQueue; WS-aware graceful shutdown, realSELECT 1/health(503 while draining),validateEnv.tsboot validation (prod requires ≥32-byte base64TALYN_TOKEN_KEY— prod key verified compliant before deploy); xact-scoped pg advisory locks (advisoryLock.ts) on taskQueue/mergeQueue/autoMergeWatcher/cloudPoller/reconcileSweep ticks + blocking lock on the migrator (xact-scoped because session locks break through Supabase's transaction pooler; pglite passes through — documented); bounded dispatch retries (metadata attempt counter, 10s→10min backoff, terminal fail at 40 ≈ 6h) + per-task try/catch;trust proxy+ per-IP limits on/mcp(300/min) and the API surface (1000/min); boot sweep re-encrypting legacy plaintext credentials then deleted the plaintext read fallbacks; environment WS events owner-scoped via newbroadcastToUser(was a cross-tenant broadcast);requireEnvironmentAccesson PATCH, CLI 401 hint fixed, CLI/MCP refuse bearer tokens over http to non-loopback. - Backend suite 998 green; desktop 154 green; marketing typecheck/lint/build green.
- Follow-up (same session): merge-queue infinite 403 loop on PostHog/posthog#67815. Root cause (empirically pinned by contrast with #67814, which
talyn-app[bot]merged onto the samemaster8 min earlier): GitHub refuses App tokens — installation ANDghu_user-to-server alike, both "the integration" — from merging a PR whose head has ANY failing check, even an "optional, does not block merge" one a human can merge straight past; the refusal is403 Resource not accessible by integration. #67815's head had exactly one failing optional check; #67814's was fully green. (Ruled out along the way: App/installation permissions — both havecontents:write+pull_requests:write; ruleset bypass — not needed when the head is green; the PR itself — approved and human-mergeable. Also learned: the June 23 user-token fallback only ever helped while the stored token was a legacy classic-OAuthgho_; post App-only cutover theghu_retry is refused identically, so the fallback is dead weight except for un-rotated legacy rows.) The queue treated the 403 as a stale-summary rejection and loopedwaiting → refetch → clean → re-mergeevery tick, forever (a failing optional check isn't a queue blocker, so the summary always read clean). Fix:MergeNotPermittedForAppErrorfrommergePullRequestwhen every token flavour gets the integration-403; the merge queue lands it asblockedwithmergeForbidden: 'failing-checks' | 'hard'—failing-checks(head had a red check) self-heals: the 4b gate holds whilesummary.checks.failed > 0and auto-retries the merge once the summary goes green (rerun passed / new head);hard(no red check to blame) stays blocked until dequeue/requeue. One-shotnotifyBlockedwith the actionable reason either way. User remedy on such PRs: re-run the failing optional check (queue then merges itself) or merge manually. Iteration 2 (July 3): the queue now re-runs the failing checks itself before blocking —githubService.rerequestFailedCheckRuns(RESTPOST /check-runs/{id}/rerequest, the API twin of the UI "Re-run" button; routes to whichever app created the check — GitHub Actions, Depot, …) with its ownrerunAttemptsbudget capped atMAX_ATTEMPTS(3), statuswaitingwhile the rerun is in flight (step 2b holds on in-flight CI), self-heal merge when green; blocks only on budget exhaustion, no-permission, or no failing check to blame. Requires the Talyn Appchecks: writepermission (currently read-only) — until granted, the rerequest 403s and the block reason says to grant "Checks: Read & write"; permission investigation confirmed no permission lets an App merge past a failing check directly (the only GitHub-side lever is the ruleset bypass list, which exempts ALL rules — too broad). - Still open (owner decisions, from the audit): access model for launch (allowlist vs invites vs open signup — no invite flow exists); public repo
Gilbert09/owlexposure via marketing GitHub links (rename vs drop links); stable vs prerelease update channel (+ Intel-arch nightly gap);docs/SETUP.mdrewrite (predates cloud-only refactor); error-tracking/uptime alerting on the backend (handlers now log but nothing pages); reconcile-sweep serialization at ~50 workspaces + shared-org GraphQL dedupe; transcript retention; prod PostgREST grant check on the 3 RLS-off tables.
Users can now run an agent skill (a SKILL.md) against a PR with a cloud task, from three sources: the PR's repo (.claude/skills/*/SKILL.md, discovered via the GitHub contents API), the user's machine (~/.claude/skills, read by Electron main over new skills:list-local IPC), and skills saved to the Talyn platform (new workspace-scoped skills table, migration 0029 + RLS).
- Injection point is the prompt — neither PostHog Code nor Claude Managed Agents accepts skills/file mounts, so the skill content is inlined into
tasks.promptby a provider-awarebuildSkillPrompt(packages/shared/src/skillPrompt.ts). The NON-NEGOTIABLE git-rules blocks were lifted out ofprMergeable.tsinto exportedpostHogCodeGitRules/claudeCodeGitRulesso the mergeable + skill prompt families share them verbatim. Skill content is fenced with an adaptive~~~~fence and never truncated — one 256KBSKILL_MAX_BYTESguard; over it a skill is listed but refused ("too large to run"). - Backend:
GitHubService.getDirectoryListing/getFileContent(contents API onapiRequest— rate gate + debugBus for free);services/skills.ts(repo discovery w/ 10-min in-memory cache + stale-on-error,bumpSkillUsageupsert);routes/skills.ts(list w/SKILL_LIST_COLUMNSprojection —contentnever ships on list reads,octet_lengthfor size);CreateTaskRequest.skill→metadata.skill+ fire-and-forget usage bump intaskCreate. Newskill_usagetable (workspaceId+skillKey → count/lastUsedAt) drives the picker's "frequently used" ordering.parseRepoUrlextracted toservices/repoIdentity.ts(prMonitor now uses it). - Desktop: Wand2 button on every open-PR row (all three GitHub tabs incl. Reviews — review skills on review-requested PRs are the headline case) →
SkillPickerModal(hand-rolled search list: frequently-used top, grouped by source, keyboard nav, provider step when the default is "Ask every time") →runSkillTaskinuseGitHubActions(mirrorscreatePostHogTask; resolves content by source and links the task to the PR). New Settings → Skills section (SkillsSettings.tsx): platform CRUD, local list + "Save to Talyn", per-repo discovered skills w/ refresh. Task detail shows aSkill: <name>badge frommetadata.skill. - Tests:
skillPrompt.test.ts(frontmatter parser edge cases, fencing, git-rules sharing),skillsService.test.ts(discovery/cache/stale/oversize),routes/skillsRoutes.test.ts(CRUD + 409 + no-content-in-list projection guard),taskCreateskill metadata + usage bump, RLS cross-owner probes onskills/skill_usage, desktopskillsLib/SkillPickerModalsuites. - Immediately available (follow-up in the same session): skills are prefetched —
lib/skillsData.tsholds a renderer-side snapshot cache (stale-while-revalidate; a failed refresh never blanks a warm cache),prefetchSkillswarms local + every watched repo's discovery on workspace load (useInitialDataLoad), anduseSkillsrenders straight from the cache so the picker opens instantly populated. Prefetch also warms the backend's 10-min repo cache. - Deferred: PRDetailSheet launch button,
skill:*WS events, ETag-conditional fetches, PR-head-branch discovery, supporting files for local/platform skills (repo skills get them via the checkout path pointer).
Incident (July 2, ~08:50–08:58 UTC): a merged PR (PostHog/posthog#67377) stayed "open" in the UI until a manual refresh. Root cause was NOT the fan-out dedup shipped the day before: Railway logs show a total inbound-webhook gap — received-webhook counts per 2-min window went ~1,100 → 327 → 0 ×4 → ~1,050 — while the backend stayed healthy (pollers, SSE, outbound all fine). Nine posthog PRs merged in the gap; none of their pull_request/closed deliveries ever arrived (GitHub doesn't auto-redeliver). The safety net (reconcile sweep) didn't catch it because the tick can be deferred wholesale when the account's GraphQL budget is in reserve — and the window had rate-limit pressure (inst 140693949's REST search budget exhausted at the same minute).
Landed — REST-only close-out for deferred sweeps:
prMonitor.sweepClosedViaRest(workspaceId, cache)— diffs tracked-open rows against the repo's REST open-PR list (githubService.listOpenPullRequestNumbers, paginated/pulls?state=open), then confirms each candidate with a direct per-PR REST fetch before closing (authoritative state +merged_at; guards list-pagination races). Never closes on missing data (failed list/lookup → skip, retry next tick). Spends core REST budget only — zero GraphQL points, which is the whole point: it runs exactly when the GraphQL budget is in reserve.prReconcileSweepdeferred branch now runs it instead of skipping outright; a tick-scopedRestSweepCachededupes across workspaces (N workspaces watching one repo → ONE list call, ONE lookup per closed PR — same principle asrefreshPrAcrossWorkspaces).- Extracted
closeTrackedRow(shared bysweepClosed+ the REST pass): state/mergedAt/queue-reset write +pull_request:updatedemit. Egress win while there: the bulk tracked-open select no longer shipslastSummary(~2KB × every open row × every sweep); the blob is fetched per actually-closed row (usually 0). - Debug: deferred-event + pollerTick summaries report REST close-out counts; the REST calls ride the existing
apiRequestrecordHttp funnel. - Tests:
prMonitorRestSweep.test.ts(8) — merged/closed writes + broadcast, queue reset, never-close-on-failure (list fail, lookup fail, lookup-says-open), cross-workspace cache dedup, no-op fast paths.
Known limitation: a hard rate gate (githubRateGate engaged by an actual RATE_LIMITED response) blocks REST too via apiRequest, so the pass covers the budget-reserve deferral (the chronic state), not a hard gate. Also shipped: task-history pagination (e9c944b, separate commit — active statuses fetched in full, finished history cursor-paginated 30/page with infinite scroll).
Began the migration from GitHub-API polling to GitHub-App webhooks, with Redis as the cross-replica backbone. Built additively so the whole suite stays green and nothing is observable until the App + REDIS_URL are configured; the destructive parts (removing OAuth-only paths, deleting the now-redundant pollers) are explicit follow-ups gated on the live App. Plan: ~/.claude/plans/could-you-spec-out-harmonic-cookie.md.
Landed:
- Redis layer (
services/redis.ts) — lazy shared client + dedicated-connection factory; no-op whenREDIS_URLunset.docker-compose.yml+npm run dev:redis. - Cross-replica WS fan-out (
services/wsBus.ts) —broadcast/broadcastToWorkspacenow deliver locally and publish to a Redis Pub/Sub channel; each replica re-delivers to its own clients, deduped by a per-processREPLICA_ID. WS event contract unchanged → no desktop changes. - GitHub App auth (
services/githubApp.ts) — RS256 App-JWT signing, installation-token mint/cache/refresh/coalesce, user-code exchange, install-URL builder, suspension handling. - Hybrid auth seam in
github.ts— App workspaces (those with aninstallationIdon the integration config) use a fresh installation token for data-plane reads and the user token for viewer-identity endpoints (/user,/user/teams,/user/repos, notifications); rate-key by installation; installation-token 401s clear the mint cache instead of nuking the user integration. Legacy OAuth workspaces are completely unchanged (all existing tests green). - Install flow —
POST /github/app/install-url+ publicGET /github/app/callback(exchange user code, upsertgithub_installations, store integration w/ installationId, bulk-refresh). Migration0026_github_app.sqladds the globalgithub_installationstable. - Webhook pipeline — public
POST /api/v1/webhooks/github(raw-body HMAC verify → ownership filter → XADD → 202, mounted beforeexpress.json);services/webhookWorker.ts(Redis Stream consumer group, competing consumers, event→refreshPrfan-out across every watching workspace, 750ms coalescing);services/webhookIndex.ts(full-name→workspaces index for the filter + fan-out);services/prReconcileSweep.ts(15-min jittered safety-net re-poll). - Debug panel — new
webhookcategory +debugBus.recordWebhook(signature, drop-reason, fan-out, enqueue→process latency);redis/github_webhooksinSERVICE_INFO.
Tests added: wsBus fan-out (8), githubApp (12), hybrid-auth routing (2), webhook receiver HMAC (5), webhook worker classify/fan-out/coalesce (14), migration table assertion, debugBus webhook recorder (4). Full backend suite green.
Cutover completed (same session): went App-only. Deleted the notifications poller, the 30s Search poll + 10s fast-CI loop, and the token-health poller. refreshPr (the webhook per-PR trigger) now derives the Mine/Review bucket flags from the fetched summary + viewer identity (relationshipFlags) — so buckets stay realtime without Search — and only materializes PRs the viewer relates to. The reconcile sweep (15 min, full pollWorkspace) is the bucket/closed-PR backstop. Removed the OAuth connect flow end-to-end (routes + getAuthorizationUrl/exchangeCodeForToken + api.github.connect); every desktop connect entry point now runs the App install flow. Added expiring-user-token rotation (refreshUserToken + in-band refresh in resolveAuth) since the App has "Expire user authorization tokens" on. Full backend suite green (752, run sequentially — parallel runs flake on pglite contention only).
Remaining follow-ups: event-driven merge-queue/auto-merge nudges; status-event PR mapping (commit-scoped — caught by the sweep); per-installation pause-on-inactivity at the receiver; dedicated stream-depth (XLEN) tile; repositories.ts install-allowlist gating.
Added Claude Code as the second CloudTaskProvider, with feature parity to PostHog Code.
Phase 0 (spike-first gate). Web research + a throwaway exploratory spike (scripts/spikes/spike-claude.ts, git-ignored) settled the two API choices against real accounts:
- Codex Cloud → deferred. OpenAI exposes no server-to-server cloud-task API — only the
codex cloudCLI (needs a self-hosted runner + opaque env ids, unstable JSON) or@codexGitHub mentions. Building on it would reverse the cloud-only refactor, so it's parked behind the same provider seam. - Claude → Anthropic Managed Agents API (not Routines: Routines are subscription-billed but fire-and-forget / no transcript / no cancel). The spike confirmed the full contract by opening a real PR on
owl(#8):POST /v1/agents(prebuilt toolset + GitHub MCPalways_allow) →/v1/environments→/v1/vaults+/credentials(static_bearer bound to the MCP URL) →/v1/sessions(agent+environment_id+vault_ids+github_repositoryresource) → post the prompt as auser.messageevent. Transcript is poll-based (GET /sessions/{id}/events;/events/streamonly replays then closes); terminal =session.status_idle+stop_reason.end_turn; the PR URL surfaces in thecreate_pull_requestagent.mcp_tool_result; cancel =user.interrupt+DELETE. Plan B (agent usesgit/gh) is dead —ghisn't installed and the mounted-repo token isn't exposed to the shell; the GitHub MCP + vault is the only PR path. Billing: standard API credits (no subscription option on Managed Agents); a self-hosted Modal-style sandbox on a Max subscription is prohibited by Anthropic ToS and enforced.
Implementation. services/claudeCode/{converter,client,credentials,executor,poller}.ts + cloudProviders/claude/provider.ts (type claude_code, displayName "Claude Code"), registered in index.ts. The lifecycle mirrors PostHog; the converter is simpler (complete polled events, no chunk coalescing). Agent + environment are created once per workspace and cached on the integration config; the vault (GitHub credential) is minted fresh per dispatch and deleted on finalize/cancel. DebugPanel SERVICE_INFO gains claude_managed_agents. Desktop: a generic CloudProviderCard (driven by the /cloud-providers routes) renders the Claude connect form (Anthropic key only — GitHub access reuses the workspace's existing connection via githubService.getAccessToken); useGitHubActions resolves a generic "active cloud env" (prefer PostHog, else Claude). Tests: claudeCodeConverter.test.ts + claudeCodeProvider.test.ts (18 cases); tsc + eslint clean across backend/shared/desktop. (Provider type was renamed claude_routine→claude_code — we use Managed Agents, not Routines.)
Follow-ups: per-task provider picker (both-connected case); checkout object shape for pr_response/pr_review head-branch mounting; executor/poller DB-mocked reconcile tests; reuse the workspace GitHub connection instead of a separate PAT; migrate the bespoke PostHog Settings card onto CloudProviderCard.
Two PR-management quality-of-life changes:
- "Ready to merge" toggle on My PRs (
MyPRsPanel.tsx): a green chip next to "Needs review" with a live count. The predicate (isReadyToMergeinprTableShared.tsx) requires: non-draft,blockingReason∈ {mergeable,checks_failed_optional} (same verdict as the backend's became-merge-ready notification), zero in-progress checks, and no outstanding review request (effectiveReviewDecisionso unprotected repos work). Parameterized coverage inprAwaitingReview.test.ts. - Merge queue: blocked PRs no longer gate the queue (
mergeQueueProcessor.ts): the tick now walks each (workspace, repo, base) group from the head, skipping past PRs that can't make progress — hard-blocked after MAX_ATTEMPTS, or no longer queued — until one takes an action.processHeadreturns aHeadVerdict('hold'= consumed the group's turn: merge/fix-run/in-flight run/waiting-no-env;'advance'= skip to the next queued PR). One-merge-per-group-per-tick serialization is preserved (firstholdbreaks the walk); a blocked head that reads clean still re-arms and consumes the turn;fixingheads still hold the group. WS badge echoes now carry the acted-on PR's real group position instead of a hardcoded 1. Nine new tests inmergeQueueProcessor.test.ts(skip-to-next, multi-skip, single-merge-per-tick, fix-run-behind-blocked, re-arm precedence, hard-cap same-tick skip, just-blocked same-tick advance + single notification, fixing holds, position echo).
Second investigation into the recurring "GitHub isn't connected" banner, now with Session 58's forensic logging (token:stored/token:removed fingerprints) in prod. Railway log archaeology across every deployment since Jun 8 produced a clean timeline and exonerated FastOwl's own storage: each incident shows the same fingerprint stored → loaded across restarts → rejected by GitHub with an authentic 401 Bad credentials (request-id logged). GitHub is revoking the tokens server-side.
Incidents: Jun 8 18:53Z, Jun 10 15:05Z (token lived ~29.5h), Jun 11 05:57Z (~12.5h), Jun 11 ~19:34Z (~11h, captured by the new REMOVING log: 401 on POST /graphql, fp:e396c488, age 11h). Hypotheses killed by the data: fixed 8h GitHub-App-style expiry (29.5h survivor), cross-workspace revoke-on-reconnect (the 05:57Z death had no connect within 12h; GitHub docs say re-auth doesn't revoke), 10-token-cap churn (only ~4 mints in 3 days; local dev uses a separate OAuth app + local DB per SETUP §0), token leak (history of the public repo is clean; the GitHub token never leaves the backend — not sent to cloud providers), full grant revocation (the second workspace's token survived the Jun 11 19:34 death). Remaining suspects are GitHub-side per-token revocations (secret-scanning-style or risk-based) — distinguishable only with exact death times and GitHub's own metadata.
Instrumentation added (the next trap):
exchangeCodeForTokennow parsesexpires_in/refresh_token/refresh_token_expires_inand logs +debugBus-records (token:expiring-grant) if GitHub ever returns an expiring grant — would prove the OAuth app has token expiration enabled.- New
githubService.checkTokenHealth(workspaceId): app-authenticatedPOST /applications/{client_id}/token(free, no user budget) returning validity, owninglogin,created_at, and any scheduledexpires_atper stored token. - New
services/tokenHealthPoller.ts(5-min cadence,TickGuard, registered astoken_healthin the Debug panel): logs each token's GitHub-side identity once (token:health-first-check— immediately answers "which GitHub login is each workspace using" and "is an expiry scheduled"), and pins a revocation to a 5-minute window (token:health-died) instead of whenever a budgeted call next 401s — the detection lag that made this autopsy ambiguous. Pure observer; removal stays with the 401 path. - Tests:
tokenHealthPoller.test.ts(10 cases over the pureTokenHealthTracker: first-sighting, expiry surfacing, steady-state silence, died transition, dead-at-first-check, replacement fingerprint, per-workspace independence).
Next time the banner appears: grep Railway for token:health-died for the death window, then check github.com/settings/security-log (action:oauth_access.destroy) and email for GitHub revocation notices at that timestamp.
Session 58 — Merge-queue stall audit: bounded body reads, verify-merged recovery, watchdogs everywhere
Post-mortem of the prod merge-queue freeze (3 queued PRs; the head — PostHog/posthog#62654 — merged on GitHub at 19:13:50Z but the UI showed "QUEUED #1 · MERGING" forever and the siblings never advanced; Tom merged them by hand at 19:19). Railway logs had the smoking gun: [mergeQueueProcessor] previous tick wedged for 304973ms — force-releasing the lock. Root cause chain:
fetchWithTimeoutonly bounded the headers. It cleared its abort timer the momentfetchresolved, so everyresponse.json()/text()after it was unbounded — the merge PUT's response body stalled and the tick hung after GitHub had already merged, so thestate='merged'DB write never ran. (The 30s timeout was added for exactly this wedge class and only half-fixed it.)- The PR monitor had no wedge watchdog (bare
if (isPolling) return), so the rescue path —sweepClosedflipping rows that fell out of the open search — was wedged alongside (no monitor logs after 18:57). The watchdog added to the merge processor after the first prod wedge was never propagated to the other six loops. - Nothing ever asked GitHub "is this PR actually merged?" — post-watchdog ticks re-attempted the merge, got 405, set
waiting, and looped. sweepClosedleaked queue bookkeeping — it flippedstatebut leftmergeQueued/mergeQueuedAt/mergeQueueStateset (unlikereconcileTerminalState), and never rebroadcast positions.
Fixes:
github.ts:fetchWithTimeoutnow consumes the body inside the abort window and returns aTimedResponse(status/headers/bodyText); all REST + GraphQL body reads go through it (parseJsonBodyhelper).listNotificationsand the OAuth token exchange — previously plainfetchwith NO timeout — converted too.describeApiErrorfolded intodescribeApiErrorFromText.- New
services/tickGuard.ts(TickGuard:tryBegin/end/active, force-release past 5 min) adopted by all seven loops: mergeQueueProcessor (replacing its inline watchdog), prMonitor poll + fastPoll, prAutoMergeWatcher, notificationsPoller, rateLimitPoller, cloudProviders/poller. mergeQueueProcessor: newverifyMerged()(RESTmerged_at, canonical) +recordMerged()(single success path). Runs on entry when the row readsstatus='merging'(a tick died mid-merge), onmerged:false, and on a thrown merge — so a lost response, a redeploy mid-merge, or an external merge all converge to the success path instead of a doomed retry loop. Plus a last-moment re-read ofstate+mergeQueuedbefore the merge call (a force-released wedged tick can resume minutes later on a stale snapshot), and a per-tick self-heal that clears queue flags on any non-open row (+ rebroadcast) as the catch-all.QUEUE_RESET_COLUMNSshared frommergeQueueBroadcast.ts; applied insweepClosed(same write as the state flip,mergeQueued:falsein its WS emit, positions rebroadcast when a queued row is swept), the processor, andreconcileTerminalState.- Deliberately NOT changed:
prCache.upsertRowdoesn't clear queue flags — if the refresh path cleared them, the processor'sdequeue()(which owns the position rebroadcast) would never fire; the self-heal covers stragglers within one tick. - Tests (+16):
githubFetchTimeout.test.ts(incl. the stalled-body-after-headers prod case via signal-wired mock streams),tickGuard.test.ts, processor verify-merged recovery (5 cases incl. queue advancement after a 405-recovery), self-heal, stale-tick guard (drivingprocessHeadwith a stale snapshot), and the sweep clearing flags + promoting the surviving sibling #2 → #1.
Observed-but-not-fixed: the PostHog Code SSE tail loop re-reads ~5.5k frames every ~10s per watched run (Session 57's leftover, confirmed flooding the prod logs), and a GraphQL primary-rate-limit exhaustion at 17:25 set the degraded stage for the incident.
Diagnosed a Railway network spike (~40MB/bucket for ~40 min, flat CPU/memory): every in-progress PostHog Code task streamed its SSE log 24/7 (token-level ACP deltas — single tasks delivered 12k+ events in a 2-minute window) and the streamer persisted the full transcript jsonb to Supabase every 25 events (PERSIST_EVERY) — ~500 full-blob UPDATEs per task per 2 minutes during bursts, quadratic over a run's life. Nothing functional needed the always-on stream: status/PR/finalisation all come from the poller's getTask() REST poll + bounded getSessionLogs tail fetches, and terminal-with-empty-transcript runs already get a one-shot durable S3 backfill. The stream's only job is the live transcript view.
Fix — stream only while someone's looking, write on a clock not a counter:
- New
services/cloudProviders/taskWatch.ts(mirrorsprFocus): in-memorymarkWatched/isWatched/clearWatched, 90s TTL, lazy expiry.CloudTaskRowgainswatched(stamped by the generic poller from the registry; no query change). posthogCode/poller.tsgate rewritten: terminal+empty-transcript → one-shot backfill (unchanged, unconditional); running+watched → live stream; otherwise tear down via newstreamer.isActive()(stop persists buffered events).finalize()clears the watch.streamer.ts:PERSIST_EVERY = 25→PERSIST_INTERVAL_MS = 10sdebounce (check-on-append; stream-end tail +flushNowcover the rest). Worst case on hard crash: ≤10s of mid-run snapshot, and finished runs stay durable via the terminal backfill.- Routes:
refresh-logsmarks watched before the remote call (so the run-not-started 409 still arms the poller); new lightweightPOST /tasks/:id/watchheartbeat (no remote call, no row read beyond access check); stop/delete clear the watch.executor.tsno longer opens a stream on dispatch — the task screen's refresh-logs starts it instantly for a viewer, SSE replays from the start for late viewers. - Desktop:
api.tasks.watch()+ a 30s heartbeat effect inTaskTerminalwhile a cloud task is mounted andin_progress. Deliberately no unwatch-on-unmount (two windows viewing the same task would race); the TTL lapse costs ≤90s of tail. - Tests (+15):
taskWatch.test.ts(fake-timer TTL semantics),posthogCodePollerGating.test.ts(parameterized over the four gate arms + watch-cleared-on-finalize; gotcha:finalize's void-edcaptureOutcomeDB read races pglite teardown — settle beforecleanup()or the WASM wedges the worker), streamer debounce test (30-event burst stays buffered; old count trigger would have flushed at 25) +isActive()lifecycle.
Net effect: an unwatched fleet of cloud runs (the exact spike scenario — pr-followup batches) costs only the 10s status poll; transcript bytes flow only for the task on screen, at ≤1 full-blob write per 10s. Known leftover (pre-existing, now bounded to watched tasks): the SSE edge kills streams every ~2 min and Last-Event-ID resume sometimes re-replays history — worth chasing separately if watched-task traffic still looks fat.
Session 56 — Refactor-debris sweep: dead client code, doc drift, silent catches, missing tests, README
A "what have we overlooked?" audit of the cloud-only refactor's leftovers, worked through as five focused commits. (Started in one Claude session, finished in another after API errors killed the first mid-edit.)
- Dead desktop client code removed (−822 lines). The Session 52 audit cleaned the task-screen buttons but missed the API layer:
api.tsstill exported fullagents+backlogAPI objects and daemonpairing-token/updateDaemoncalls — all 404 against the cloud-only backend. Stripped those plususeAgents, agent state in the workspace store, the interactive permission flow inAgentConversation(Approve/Deny/Allow-always buttons +respondToPermission; permission cards remain as a read-only historical record), and the matching shared types (Agent,AgentStatus,Backlog*, permission/WS event interfaces) + backend WS emitters (agent:*,task:output,task:agent_status). Also deleted thepackages/daemon/husk (dist + node_modules; source was already gone). - Docs pruned.
ROADMAP.md's priority queue / backlog / known gaps described the local-execution app; rewritten around the actual current work (cloud provider Phases 0+3–5, desktop generalisation, advisory locks, auth polish, desktop tests), with obsoleted items struck through and Phases 1–20 bannered as pre-refactor history. Marked resolved gaps: credential encryption (landed astokenCrypto.ts), backend bundling/release packaging (hosted on Railway).QUALITY_PARITY.md's unread-dots item now notes itsinbox_itemsdata source was dropped in Session 43. - Silent error swallowing fixed. Eight
.catch(() => {})hot-path sites now log with context: pr_monitor tick crashes (previously reportedok:trueto the debugBus while the rejection vanished), best-effortrefreshPrcalls in notificationsPoller / mergeQueueProcessor (freshness + post-merge-failure refetch) / prAutoMergeWatcher, analytics capture, and the PostHog streamer's last-resort backfill. Deliberately left: control-flow null fallbacks (WS auth, rate-limit login lookup) andtaskMetadataMutex's chain de-poisoning (the error still propagates to the caller). - Tests for the untested newer services (33 new):
prCloudFix(owner-scoped env resolution, linked-task status),taskCreate(defaults, metadata overrides, PR pointer stash + reverse-link incl. cross-workspace rejection and link-failure tolerance,task:createdbroadcast), andtaskMetadataMutex— the concurrency edge it exists for: concurrent patches serialize instead of tearing, a throwing patch doesn't poison the chain. - README updated for the PR-management + self-fixing pitch: removed the stale "prioritized inbox" framing (Inbox died in Session 43) in favour of the GitHub panel's needs-attention buckets, and added the self-fixing story (merge queue + keep-mergeable flag → automatic cloud fix runs when a PR falls behind / conflicts / fails CI).
Diagnosed why the prod integrations row (GitHub token) kept vanishing, forcing reconnects. The chain: (1) any single GitHub 401 hard-deletes the row — githubService.removeToken() is called from apiRequest, listNotifications, and executeGraphql; (2) local dev shared everything with prod — same Supabase DB, same TALYN_TOKEN_KEY, same classic GitHub OAuth app — so a laptop tsx watch backend polled GitHub against the shared row; (3) connects never revoke old tokens at GitHub, so they pile up toward GitHub's 10-tokens-per-user/app/scope cap, after which every reconnect silently revokes the oldest token — whichever running backend still cached it in memory then 401s and deletes the shared row (wiping the new token), forcing another reconnect → another minted token → self-sustaining loop. Confirmed in Railway logs: single GraphQL 401 at 15:05:57Z, next tick every repo "not connected".
Fix landed this session — environment separation:
supabase initat repo root + local stack vianpm run dev:db/dev:db:stop(excludes storage/realtime/functions/etc; db on:54322, API/auth on:54321, Studio on:54323).supabaseCLI added as root devDependency (brew blocked on outdated Xcode CLT).supabase/config.toml: GitHub login provider enabled viaenv()from gitignoredsupabase/.env;fastowl://auth-callbackadded toadditional_redirect_urls.packages/backend/.env+apps/desktop/.envrewired to the local stack with a freshly generated dev-onlyTALYN_TOKEN_KEY; prod credentials removed from the laptop entirely (they live only in Railway variables now). Backend boots clean against local: all 24 migrations apply on startup, 8 tables created.docs/SETUP.md§0 documents the new local-dev flow + the two dev-only OAuth apps Tom still needs to create in the browser (login app → callbackhttp://127.0.0.1:54321/auth/v1/callback; integration app → callbackhttp://localhost:4747/api/v1/github/callback).
Still open (backend hardening, not started): don't hard-delete the integration on a single 401 — re-read the row first (another process may have rotated the token), mark invalid instead of deleting, and revoke the old token at GitHub (DELETE /applications/{client_id}/token) on disconnect/reconnect so tokens stop accumulating toward the cap.
Follow-up — fresh-DB renderer bugs the switch exposed (first dev login landed on an empty MainLayout with a misleading "OAuth isn't configured" banner + "workspace not found"): (1) useInitialDataLoad now runs the inverse onboarding migration — server has zero workspaces but localStorage says onboarded → re-show the wizard (previously the user was stranded with no way to create a workspace); (2) a persisted currentWorkspaceId that no longer exists is cleared when there's no fallback workspace, instead of being left to 404 every per-workspace fetch; (3) SettingsPanel.refreshGitHubStatus no longer fabricates {configured: false} on any fetch error (that's what painted the global "OAuth isn't configured" banner when the stale workspace id 404'd) — failure now leaves status unknown (null), and the "Not Configured" badge requires an explicit configured === false.
Session 54 — Frameless macOS window: hidden title bar, inset traffic lights
Dropped the native macOS title bar (titleBarStyle: 'hiddenInset' on the BrowserWindow, darwin-only; other platforms keep their frame) so the close/minimize/zoom buttons float flush over the app UI. The renderer reserves drag regions for them:
- Preload exposes
platform; newisMacDesktophelper inlib/utils.ts+.app-region-drag/.app-region-no-dragCSS utilities inApp.css. - MainLayout: the Sidebar reserves an in-flow 36px drag strip above the workspace switcher (the traffic lights sit in it; double-click-to-zoom works natively).
SystemStatusBannermoved from above-the-sidebar into the main column so the sidebar always reaches the window top and the banner can't sit under the lights. - Chrome-less screens (boot spinner, login, onboarding, backend-unreachable) render a fixed full-width
MacDragOverlaystrip instead — safe there because their content is centered; MainLayout deliberately doesn't use it since it would swallow clicks on panel-header controls near the top edge. - Follow-up: every page's top header bar (GitHubPageShell, Task Queue list + both task-detail headers, Settings, Debug) is itself an
app-region-draghandle, with buttons/selects/PR controls opting out viaapp-region-no-drag— so the area around the page title drags the window everywhere. Sidebar strip tightened 36px → 24px so the workspace picker hugs the traffic lights.
Session 53 — Analytics audit + instrumentation: data-quality fixes, business events, server-side task lifecycle
Audited FastOwl's PostHog project (459813): only app_opened + panel_viewed existed, the app_version super property never landed on any event (registered async after an IPC round-trip, silently failing), all 77 $exceptions were one string (WebSocket error: [object Event] — capture_console_errors × the reconnect loop), autocapture had no data-attrs to target, and none of the product's real actions emitted events. Fixed all of it:
- Data quality.
TALYN_APP_VERSIONis now baked at webpack build time fromrelease/app/package.json(CI stamps it pre-build, so it matchesapp.getVersion()) and registered synchronously with a newenvironment(development/production) super property; IPC fallback only if the bake is missing. The WSonerrorhandler now logs socket URL + readyState + attempt count, and only the FIRST failure of an outage usesconsole.error(→ one$exceptionper outage, not per retry); later attempts downgrade toconsole.warn. The activeworkspace_idis registered as a super property;panel_viewedgainedprevious_panel. - Renderer business events (all via
trackEvent):pr_merged{repo, pr_number, blocking_reason},merge_queue_toggled,pr_fix_task_started,pr_detail_opened,github_connect_started,cloud_provider_connected,task_created{task_type, model, runtime_adapter, from_pr},task_aborted/task_retried/task_cancelled/task_started_manually/task_deleted,logged_in/logged_out(transition-gated so session restore doesn't fire it),onboarding_completed{github_connected, repos_watched}. Plusdata-attrs on the key controls (sidebar nav, PR-row merge/queue/fix/copy, task Add/Start/Abort/Retry/Delete/Cancel, composer submit) so autocapture stops being Tailwind class soup. - Server-side task lifecycle — new
packages/backend/src/services/analytics.ts: a deliberate non-SDK, single-fetchPostHog capture client (keeps the call inside the debugBus outbound-HTTP funnel; no flag/batch machinery needed at this volume), env-gated onTALYN_POSTHOG_KEY/TALYN_POSTHOG_HOST, attributing events to the workspace owner (same Supabase user id the renderer identifies, so one person profile).taskQueueemitstask_dispatched{provider, task_type, priority, duration_queued_ms} + stampsmetadata.dispatchedAt, andtask_dispatch_failed{reason}; the posthog poller'sfinalizeemitstask_completed/task_failed{opened_pr, duration_total_ms, duration_run_ms, error_reason} via a projected read (never the transcript). DebugPanelSERVICE_INFOgot theposthog_analyticsentry. Newanalytics.test.ts(7 tests: env-gating, payload shape, host override, owner resolution, unknown-workspace drop, failure swallowing).
Note: the renderer compiles the whole analytics path out when no key is baked (Terser proves !KEY), so local keyless builds ship zero analytics code. Backend events need TALYN_POSTHOG_KEY set on the Railway service — not done this session (Railway MCP unauthorized). Typecheck + lint clean, 559 tests green.
Session 52 — Task-screen action audit: dead review-flow buttons removed, Abort cancels the cloud run
Audited every button on the task screens (queued / in-progress / completed) against the cloud-only architecture, then removed what the refactor had orphaned and fixed what half-worked.
Dead UI removed (all of it called endpoints deleted in the cloud-only refactor, or was unreachable):
- Finish (TaskTerminal) →
POST /tasks/:id/ready-for-review(404). The wholeawaiting_reviewconcept is gone: removed the status fromTaskStatusin shared, the "AWAITING REVIEW" list section, the Create PR (/approve, 404) and Reject & Requeue (/reject, 404) buttons, the auto-commit banners (theirmetadata.autoCommitis never written by cloud runs), the CLIfastowl task readycommand, the MCP status-filter doc, and thetaskAwaitingReviewbadge state inprTableShared.tsx. Pruned the legacyfindTaskHoldingEnvRepoSlothelper (taskQueue.ts) that was the last backend reference. - Queue / Unqueue (queued↔pending) — misleading: the scheduler dispatches both
pendingandqueued, so "Unqueue" paused nothing, and tasks are createdqueuedsopendingwas only ever reachable via the button itself. (pendingstays inTaskStatus— it's the DB column default and legacy rows may carry it.) - "PR failed → Retry" strip +
POST /tasks/:id/retry-pr+services/taskPullRequest.ts— the stub could only ever 502 ("provider opens its own PR"). A barepullRequestErrornow renders as a "No PR linked" tooltip note. - The whole non-cloud rendering branch: Terminal/Files/Git tabs,
TaskFilesPanel,TaskGitPanel,TerminalHistory,useTaskFiles,useTaskGitLog, the+NN -MMdiff stats in the task list, and theirapi.tsclient methods (getDiff/getChangedFiles/getFileDiff/getGitLog/getTerminal) — the backend routes no longer exist, and the branch was reachable for tasks with a missing/malformed env row. Both task-detail views now always render the TaskTerminal transcript.
Abort actually cancels now. POST /tasks/:id/stop used to just drop the log stream and mark the task failed while the PostHog Code run kept executing (and could open a PR FastOwl would never link, since the poller only reconciles in_progress). Added the optional cancel?(task) seam to CloudTaskProvider; the PostHog provider implements it via PATCH /tasks/:id/runs/:runId/ {status: cancelled} (PostHog has no dedicated cancel action — the PATCH signals the Temporal workflow; verified against products/tasks/backend/api.py). Stop now: remote cancel (best-effort, failure noted in the result as "may still finish") → stopStreaming → task lands in cancelled (not failed) with "Cancelled by user". New routes/tasksStop.test.ts (8 tests) covers the happy path, failed remote cancel, providerless task, and the 400 non-running guard.
Smaller fixes along the way: PATCH /tasks/:id now emits task:status on a status change (Cancel previously only updated the calling client); cloud-task detection unified on the shared readCloudTaskMeta/readCloudTaskProvider helpers (was three different hardcoded-PostHog checks across TaskTerminal/TaskDetail/TaskListItem — a second provider would have broken all of them); the cloud-run banner + PR-status-pill sheet now also work on the in-progress view (the PRDetailSheet was only mounted in the non-running return).
Typecheck + lint clean, 552 tests green across the workspaces (backend 545 incl. the 8 new).
-
Diagnosed prod auto-update not finding v0.1.2. electron-updater's GitHub provider derives an update channel from the running version's prerelease identifier — a client on
0.1.1-nightly.…only matches releases whose tag also carries thenightlychannel (stable tags are excluded; onlyalpha/betaget cross-channel promotion). Confirmed live: the client's log resolved "latest version: 0.1.1-nightly.202606091540" hours after v0.1.2 published. Since the new plain-patch nightly versioning (d105c9) means no future release will ever carry thenightlychannel again, every installed*-nightly.*client is permanently stranded and needs one manual reinstall (v0.1.2+); after that the channel inference returns null andallowPrereleasepicks the newest release regardless. No code change needed. -
Desktop polish: removed the CLI/MCP token copy card from Settings → Account. Added a centered loading state to
GitHubPageShell(covers My PRs / Reviews / Merge Queue) while the initial open-PR fetch is in flight; the PR store now bootsloading: trueso the empty state can't flash before the first fetch effect runs (usePullRequestSyncclears it when no workspace is selected). -
Reconnect catch-up audit + fixes. Audited every WS-fed renderer surface for staleness across a socket outage (broadcasts are fire-and-forget). Already-correct: task reconcile, open-PR re-list, WS-client subscription/debug-filter replay, view-cohort re-announce, Debug-panel snapshot polling. Fixed the gaps: new
hooks/useOnReconnect.tscentralises the genuine-reconnect pattern (existing task/PR reconciles refactored onto it);TaskTerminalre-runs transcript hydration on reconnect (missedtask:events were otherwise unrecoverable — the list payload dropstranscriptfor egress andreconcileTasksre-attaches the local copy; merge dedups on seq so re-hydration is idempotent);PRDetailSheetrefetches the open PR's detail (its local state only updated via its own WS subscription and never re-read the store); environment list + sidebar cloud-provider status refetch on reconnect. Consciously accepted: missed one-shot notifications (merge_queue:blocked, awaiting-review) — state recovers via the re-lists, only the toast is lost. Desktop tests green (56), tsc + lint clean. -
Get-mergeable prompt realigned to PostHog Code's signed-git tools (PostHog/code#2574). The sandbox blocks raw
git commit/git push; publishing goes throughgit_signed_commit(now refuses mid-merge — publishing a local merge linearized it, attributing every base-branch change to the PR),git_signed_rewrite(refuses ranges containing merge commits), and the newgit_signed_merge(server-side two-parent Verified base merge, the "Update branch" machinery; 409 → rebase path). Our prompt's old rules — real local merge, never rebase, never force-push — were unfollowable there, which is why they "weren't being listened to". RewrotebuildPostHogPrompt(packages/shared/src/prMergeable.ts, shared by the desktop button + auto-keep watcher + merge queue) around the sanctioned paths:git_signed_mergefirst for base updates; conflicts via the only sanctioned rebase (rebase origin/<base>→ resolve →rebase --continue, NOTgit commit→git_signed_rewrite); tool refusals are authoritative (follow their recovery text, no workarounds); kept the before/after file-set leak guard, the path-agnostic ancestor/behind-by assertions, and the single-parent-imitation ban.buildPostHogPrompt.test.tsrewritten to lock the new contract (9 tests); backend green (543).
Reported: prod merge queue had 15 mergeable PostHog/posthog.com PRs and nothing was merging. Pulled Railway deploy logs + queried the prod DB (Supabase). The queue had drained the group fine from 10:54–11:08, then went dead silent — 15 PRs frozen at the pristine {status:"waiting", attempts:0} the toggle route writes (no lastError, no fix_task), all CLEAN/MERGEABLE, freshly polled by the independent prMonitor loop. No [mergeQueueProcessor] log lines (log search verified reliable).
Root cause. MergeQueueProcessor.tick() sets this.ticking = true and only clears it in finally; every tick first does if (this.ticking) return;. Every awaited GitHub call in processHead went through github.ts apiRequest/executeGraphql, which used Node's global fetch (undici) with no timeout / AbortController — so a stalled socket (one merge request ~11:08) hung indefinitely, leaving ticking === true forever. Every subsequent 10s tick no-op'd: no merges, no fix dispatches, no errors. Other pollers (prMonitor) kept running, which is why the rows looked healthy but never merged.
Fix (two layers). (1) fetchWithTimeout helper in github.ts wraps every GitHub fetch in a 30s AbortController timeout, surfacing a descriptive throw instead of a hang; apiRequest rethrows it (already records to debugBus), executeGraphql records + retries it like a transient 5xx. (2) A watchdog in tick(): if ticking is still held past MAX_TICK_MS (5 min) the next tick force-releases the lock (logs previous tick wedged for …) so the loop self-recovers even if a non-HTTP await (DB / cloud-dispatch) stalls. New tests: request-timeout abort + graphql network-error retry (githubService.test.ts), wedge-recovery (mergeQueueProcessor.test.ts). Backend green (114 in the touched suites), tsc + lint clean.
Note: a redeploy of fastowl-backend is what clears the current in-memory wedge (a fresh process starts with ticking=false and drains the 15); the code fix prevents recurrence.
Reported real-world failure: when a merge-queue / auto-keep-mergeable cloud fix run merges the base branch in to clear conflicts, base-only file changes occasionally leaked into the PR's diff. The "make this PR mergeable" prompt (buildPostHogPrompt in packages/shared/src/prMergeable.ts) already told the agent to merge (not rebase) the base in and do a one-line stray-change check; strengthened that into an explicit before/after file-set guard: capture git diff --name-only origin/<base>...HEAD BEFORE the merge and again AFTER resolving conflicts, require the two sets to be identical, per-file review the remaining hunks, and git merge --abort + redo (taking the base side for untouched files) on any leak — never push until the sets match. New buildPostHogPrompt.test.ts locks the guard's intent (before/after file-set check, base branch threaded into the commands, no-force-push/no-rebase rules retained) without over-asserting wording. Backend green (488).
Root-cause follow-up (same session). Diagnosed the actual failure on PostHog/posthog#61657 (786 files, +54.8k/−11.9k on a ~10-file Pendo PR). The "Merge branch 'master'" commit was a single-parent commit — a squash-merge of the base, not a real merge. Because master never became an ancestor (behind_by: 160, merge-base frozen at the original branch point), the three-dot PR diff attributed all 160 commits of master's churn to the branch. Hardened the prompt against exactly this: (1) the non-negotiable rules now forbid git merge --squash and equivalents (read-tree / checkout base -- . / apply) and require a true TWO-parent merge commit, with the no-rewrite rule scoped to pushed history plus a carve-out for undoing a local unpushed botched merge (git reset --hard ORIG_HEAD); (2) condition 3 adds a deterministic post-merge assertion — git merge-base --is-ancestor origin/<base> HEAD must pass, git rev-list --count HEAD..origin/<base> must be 0, and the merge commit must have two parents, else reset and redo. Two new test cases assert both guards. Backend green (490).
Follow-up sweep for other wasteful reads after the Session 47 transcript-poller fix. Verified findings (several of an earlier audit's "criticals" didn't hold up — taskQueue.getQueuedTasks only selects pending/queued tasks whose transcript is null, and prMonitor is already fully column-projected):
-
GET /taskslist pulled every transcript, then discarded it.routes/tasks.tsselected{ task: tasksTable }(all columns incl. the MB-scaletranscript) butrowToTaskdrops the transcript withoutincludeTranscript— so the blob left Postgres only to be thrown away in the serializer. Load-triggered (app launch / workspace switch / WS reconnect), so it never showed as a steady ramp but could be tens of MB per call for transcript-heavy users. Fix: ataskColumnsNoTranscriptprojection inservices/taskSerialize.ts(co-located withrowToTask, which now accepts a transcript-optional row); the list selects that. Single-taskGET /:idstill selects the full row (transcript intentional). Newroutes/tasksList.test.tspins both behaviours. -
mergeQueueProcessor(10s) +prAutoMergeWatcher(60s) bareselect()ofpull_requestsrows. Small today (~2 KB rows; onlylastSummaryis sizable and it's used) — done mainly as defense so a future large column onpull_requestscan't silently leak. Each now selects aQUEUE_COLUMNS/WATCH_COLUMNSprojection, andPRRowis narrowed toPick<…, keyof projection>so the compiler enforces completeness — read a column not in the projection and tsc fails. Both the live and the freshness-reread selects are covered.
- Backend green (485), tsc + lint clean. The list fix is the meaningful one; the PR-loop changes are hygiene/defense.
A single user's Supabase egress hit ~8 GB in one billing period (5 GB free + 2.92 GB overage), ramping from ~0 to 2.1 GB/day. Two parts:
-
Debug-panel DB metering (observability). Wrapped the postgres-js client's
unsafe()— the single choke point every Drizzle query funnels through (seedrizzle-orm/postgres-jssession) — indb/client.tsto estimate the bytes each result pulls back and the query count. New'db'DebugCategory, adebugBus.recordDbQueryrecorder with cumulativedbStats(egressBytes + requests, reset on Clear), and two snapshot-bar tiles ("DB egress" / "DB queries") plus the stream rows. Measurement is skipped while the panel isn't recording, so the serialize cost is only paid when watching.isRecording()exposed for that gate. Tests:dbEgress.test.ts(proxy mechanics — await vs chained.values(), count-once, recording-off-still-executes, rejection, BigInt) +recordDbQuerycases indebugBus.test.ts. -
Root-cause fix.
cloudProviders/poller.tsrandb.select()(all columns) over everyin_progresstask every 10s only to compute one boolean — includingtranscript, the cloud-run conversation log (often MBs). At 8,640 ticks/day a single stuck-in-progress task with a ~250 KB transcript ≈ 2.1 GB/day, matching the ramp. Narrowed the SELECT to the columns the scheduler needs and compute emptiness server-side viaCASE WHEN jsonb_typeof(transcript) = 'array' THEN jsonb_array_length(transcript) = 0 ELSE true END(theCASEboth guardsjsonb_array_lengthfrom throwing on non-arrays and — unlike the firstNOT(... )draft — never returnsNULLfor a null transcript, which a test caught:nullwould have read falsy and suppressed the terminal-run backfill stream). The streamer keeps its transcript in memory and overwrites on flush, so it never reads the column back — narrowing can't break streaming. Also removed the deadtick()/init()/shutdown()loop inposthogCode/poller.ts(never scheduled — onlyreconcileTaskis used via the provider) that carried the sameselect()-all leak. Tests:cloudPollerEgress.test.tspins the SQL to the old JS semantics across null /[]/ populated-array / non-array-object via real pglite.
- Backend green (483), tsc + lint clean. Per-tick payload drops from MBs to bytes; 10s cadence left as-is.
Two reported inconsistencies on the GitHub panel:
-
Badge swap. The merge-queue indicator was a single if/else, so "Queued #N" was replaced by "Fixing"/"Merging"/"Blocked" — you lost the queue-membership info while a run was active. Now the "Queued #N" badge stays visible the whole time the PR is queued, with the activity badge (Fixing / Merging / Blocked) rendered alongside it.
-
Backend-created tasks were invisible. Merge-queue (and auto-keep-mergeable) fix runs are created via
createCloudTaskon the backend, which broadcast nothing — so they never entered the desktop task store. Result: they didn't appear in the Tasks screen, and the PR's task badge (rendered offrow.taskId) deep-linked to a task that wasn't there → "Task not found". Fix:- New
task:createdWS event (TaskCreatedEvent) emitted fromcreateCloudTask— covers the route, the merge queue, and the watcher in one place. ExtractedrowToTaskintoservices/taskSerialize.tsso the route andtaskCreateserialize identically without a route↔service cycle. - Desktop
useApiConnectionadds atask:createdhandler that adds the task (deduped by id, so the optimistic add from the desktop's own create is unaffected).addTaskis now idempotent (skip-if-present) so no source can double it or clobber richer local state. - Deep-link hardening: clicking a PR's task badge for a task not in the store now fetches it on demand (
api.tasks.get) before navigating, so the link always resolves even if the broadcast was missed (client connected after the run started).
- New
- Tests: merge-queue fire path now asserts
task:createdis broadcast; new desktopaddTaskidempotency suite. Backend green (460), desktop (38), tsc + lint clean.
A queued PR was spawning far more than the 3-attempt budget of cloud fix runs (one PR had 7). Two compounding in-process bugs in mergeQueueProcessor:
- Counter reset by a transient clean reading. Right after a fix run pushes commits, GitHub recomputes mergeability async, so the cached summary briefly reads
MERGEABLE/UNKNOWN. Bothattempts = 0resets (the accountingelsebranch and the step-4 re-arm) fired on that transient lie, so the cap never tripped and the queue fired runs forever. Fix:attemptsis now monotonic — only ever incremented; a genuinely-fixed PR leaves the queue via a successful merge (the only trustworthy "fixed" signal), so no reset is needed. Added a hard cap at the fire site as an absolute backstop (never fire whenattempts >= MAX_ATTEMPTS, even if a failed-merge flap downgraded the status). - Active-run guard keyed on
row.taskId.attachTaskToPullRequestRow(called by any task created against the PR — a manual task, the auto-keep watcher) reassignspull_requests.taskId, so the guard could check the wrong task and fire a duplicate while the queue's own run was in flight. Fix: guard on the queue's ownstate.lastFixTaskId(plus any other run still pointed to byrow.taskId).
- 3 regression tests (no-reset-on-transient-clean / hard-cap-after-flap / no-duplicate-while-own-run-active). Backend green (460), tsc + lint clean.
- Known twin:
prAutoMergeWatchershares the same two patterns (transient-clean resets +row.taskIdguard). It already has a fire-site hard cap so it's less exposed, and its re-arm-on-genuine-clean is intended (long-lived watcher) — so left untouched pending a decision on distinguishing transient vs genuine clean. - Deployment note: the in-process serialization + own-run guard make this correct at 1 replica (current). A multi-replica backend would still need a DB-level claim (atomic compare-and-set) before firing.
When the merge queue exhausts its retry budget (3 failed cloud fix runs) a PR flips to blocked and waits for a human — good, but silently. Added a notification on that transition, plus the reason.
- Backend:
mergeQueueProcessornow detects the transition intoblocked(fire-once, not every 10s tick), captures a human reason via a new sharedmergeBlockerReason()helper (conflicts / changes requested / unresolved threads / failing CI, with "behind its base" special-cased offmergeStateStatus), stores it on the queue state, and emits a dedicatedmerge_queue:blockedWS event (emitMergeQueueBlocked). A dedicated event — not the idempotentpull_request:updated, which replays on reconnect — guarantees exactly-once. The reason also rides the badge state (publicState+ the list route'spublicMergeQueueState) so a freshly-loaded blocked PR explains itself. - Desktop: a top-level (panel-independent)
merge_queue:blockedhandler fires both an OS notification (resurrected the ElectronNotificationbridge) and an in-apptoast.error, gated by a re-added Settings → Appearance → Notifications toggle (fastowl:notify:mergeBlocked, default on; OS path also needs granted permission, requested lazily). Clicking the OS notification focuses the app and jumps to the GitHub panel. The blocked badge tooltip now shows the reason. - Kept the manual-intervention model (no auto-dequeue; auto-re-arm on a clean observation) unchanged.
- Tests: 9
mergeBlockerReasoncases + 3 processor cases (notifies once with reason / no re-notify while blocked / reason persisted) + 2 desktop pref-helper cases. Backend green (457), desktop (36), tsc + lint clean.
Ripped out the standalone Inbox end-to-end. The prioritized "items needing attention" queue (new reviews/comments/CI failures/merge-ready) and the per-PR "unread updates" badges it powered are gone; PRs needing attention surface directly in the GitHub panel's Needs-attention / Mine / Review buckets.
- Backend: deleted
routes/inbox.ts+ its tests; dropped theinbox_itemstable (schema.ts+ new migration0023_drop_inbox.sql); removedrequireInboxAccess(middleware/auth.ts),emitInboxNew/emitInboxUpdate(websocket.ts), and the whole inbox-emission tail ofprCache.ts(emitDeltaInboxItems/createInboxItem/bot-comment suppression).prCachestill computes deltas + advances the PR-event cursors onpull_requests— that machinery just no longer materializes inbox rows.pullRequests.tslost the unread-count join, theunreadCountfield, andPOST /:id/seen. - Shared: removed
InboxItem*/InboxAction/InboxItemSourcetypes, theinbox:new|update|removeWS event types, and theirWSEventTypeunion members. - Desktop: deleted
InboxPanel.tsx; stripped inbox nav (sidebar Inbox entry + Active/Archive sub-views), store state/actions,api.inbox,pullRequests.markSeen, theinbox:new/inbox:updateWS handlers,useInboxActions, and the GitHub-panel unread dots. Default panel is now GitHub. - Backend suite green (445), desktop (34), tsc + lint clean.
The debug bus exposed ALL backend traffic to any authenticated user (Session-question finding: a single global ring buffer, unscoped /debug routes, and a broadcast()-to-everyone debug:event sink). Locked it down and made it multi-tenant-aware so it can run in production limited to operators.
- Admin gate: new
users.is_admincolumn (migration0022), surfaced onAuthUser.isAdmin. Granted via aTALYN_ADMIN_EMAILSbootstrap at login (promotes on token verify; never demotes) so no manual SQL is needed. NewrequireAdminmiddleware guards every/debugroute exceptGET /debug/access(which just reports{admin}so the desktop can hide the panel). The daemon internal-proxy identity is always non-admin. - Per-user attribution:
DebugEvent/DebugRateLimitStategainownerId/ownerLabel. The github service registersworkspaceId → {ownerId, label}(email or@github) at token load/connect;recordHttp/recordRateLimitpassworkspaceIdand the bus stamps the owner.snapshot()returns theownerslist for the filter dropdown. - Filtering:
getEvents/snapshottake an owner filter (<id>|system| all);/debug/events|snapshot?owner=plumb it. - Optimised live stream: the
debug:eventsink no longerbroadcast()s to everyone — a dedicated fan-out sends only to admin clients, and only those whose per-clientdebug:filtermatches the event's owner. So a non-admin gets nothing and an admin watching one user isn't fed everyone else's traffic over the wire. Newdebug:filterWS message +ws.setDebugFilter()(re-sent on reconnect). - Desktop: DebugPanel gains a user-filter dropdown (All / System / per-account), re-fetches backfill + snapshot on change, pushes the WS filter, and shows an "admin-only" state when
/debug/accesssays no. - To enable for yourself: set
TALYN_ADMIN_EMAILS=<your login email>in the backend.envand re-login. - 14 new tests (debug bus attribution/filter +
matchesOwnerFilter; WS admin-gating + owner-filter streaming). Full backend suite green (472), desktop (34).
Added an app-wide warning banner (full-width, top of MainLayout, above the sidebar) that surfaces when core functionality is unavailable — currently a disconnected GitHub, which silently pauses PR tracking, reviews, and the merge queue. Follows the silent-failure theme of Sessions 39–40: make the broken state loud instead of leaving the user to discover dead pollers.
components/layout/SystemStatusBanner.tsx: renders a warning row per missing service (extensible array). For GitHub: distinguishes "configured but disconnected" (amber banner + Connect GitHub action that opens OAuth, plus a settings shortcut) from "OAuth not configured on the backend" (info, no action). Renders nothing while healthy or before the first status check (no flash).stores/workspace.ts: newgithubStatusfield +setGitHubStatusso the banner reacts app-wide without prop drilling.hooks/useSystemStatus.ts: mounts once inMainLayout, reusesuseGithubConnection(fetch + on-focus re-check) and mirrors status into the store — so reconnecting via the browser clears the banner automatically.SettingsPanel.tsx: GitHub connect/disconnect now also writes the store, so an in-app disconnect surfaces the banner instantly (no focus event needed).- 5 renderer tests (
SystemStatusBanner.test.tsx) covering the show/hide matrix. Desktop suite green (34), tsc + lint clean.
Debugging a "no HTTP requests / no rate-limit tiles / pollers show 0 workspaces" report: the cause class is the backend loading 0 GitHub tokens at startup, so getConnectedWorkspaces() is empty and every GitHub poller no-ops (0ms, no HTTP). The token-load failure was silent (only a console.error in readAccessToken on a decrypt failure — typically a TALYN_TOKEN_KEY mismatch vs. when the token was saved). Confirmed it's not a regression: no recent commit touched token loading / getConnectedWorkspaces / the integrations table (only the workflow scope constant changed).
github.tsloadStoredTokens: now records atokens:loadeddebug event with{loaded, failed, rows}and anok:falsetokens:load-failedevent on a hard failure, plus a clearer console summary (Loaded N token(s) from M row(s) — K could not be read (likely a TALYN_TOKEN_KEY mismatch; reconnect GitHub to re-save)). Makes the silent killer visible in the Debug panel's Events/Errors right after a restart, and distinguishes "no integration row" (need to connect) from "row present but undecryptable" (key mismatch → reconnect).
Fixed the Debug panel's rate-limit cards vanishing after the account got rate-limited + the backend restarted. Root cause: rateLimitPoller.tick() called getViewerLogin() (a budgeted /user REST call) first and skipped the whole account if it failed — so when the account was rate-limited (or a restart wiped the in-memory login cache), the free GET /rate_limit was never fetched and the cards never repopulated. The cards live in an unpruned in-memory map, so they only clear on restart and then never came back.
rateLimitPoller.ts: fetch/rate_limitunconditionally; the login is now a best-effort label only, falling back toworkspace <id8>when it can't be resolved, so cards show even mid-rate-limit.debugBus.ts: prune rate-limit cards not re-observed within 3 min (≫ the 30s poll cadence). Makes cards honest if the poller/account goes away, and stops a relabelled fallback card lingering as a stale duplicate once the real login resolves.- Note: cards are delivered via the 3s snapshot re-pull (
recordRateLimitdoesn't emit a livedebug:event), so after enabling they populate within one poll tick. - 4 new tests (2 poller tick label-resolution incl. the rate-limited fallback, 2 debugBus staleness pruning). Full backend suite green (461).
Fixed the Queued #N badge going stale: positions only ever updated on a manual list refresh because the live pull_request:updated events carried a placeholder position (the toggle route emitted position: 0, the processor position: 1), and nothing recomputed the sibling PRs' positions when the group's membership changed (enqueue, dequeue, merge).
services/mergeQueueBroadcast.ts(new): single source of truth for queue position math —computeQueuePositions(rows)(1-based per(repo, base)group, FIFO bymergeQueuedAt) plusbroadcastMergeQueuePositions(workspaceId), which reloads the workspace's queued open PRs, recomputes, and emits apull_request:updatedper PR with its real position.- Wired the rebroadcast into every membership change: the merge-queue toggle route (after enqueue/dequeue — dequeue also emits the toggled PR's cleared badge), the processor's merge-success path (survivors shift #2→#1), and the processor's
dequeue(PR merged/closed upstream). - De-duped the position logic:
routes/pullRequests.tsnow imports the sharedcomputeQueuePositionsfor its GET list instead of a local copy, so the badge order can't drift from the order PRs actually merge. - Also reduced the merge-queue poll interval 60s → 10s, and added the
workflowOAuth scope so merges in large repos (PostHog/posthog) stop 403-ing on GitHub's workflow gate-check timeout (requires reconnecting GitHub). - 5 new tests (2 broadcast integration via emit-spy, 3 parameterised
computeQueuePositions). Full backend suite green.
Added a Copy list button to the GitHub page header that copies the currently filtered PRs to the clipboard for pasting into Slack to request approvals. Writes a rich text/html bullet list of hyperlinks (Slack/Notion/docs paste as clickable links) plus a plain-text markdown fallback (- [title](url)) via a single ClipboardItem; falls back to writeText(markdown) where ClipboardItem isn't available. Respects every active filter (relationship/repo/search/needs-attention) since it copies off filtered. Toast reports the count. GitHubPanel.tsx only.
Replaced the (non-existent) onboarding with a guided, full-screen first-run wizard, fixing the dead first run the cloud-only/PR pivot left behind. Previously the app silently auto-created a "Default Workspace" on first load, dropped the user on the empty Inbox, and buried every real setup step (connect GitHub, watch repos, connect a cloud provider) in Settings.
- Wizard (
apps/desktop/src/renderer/components/onboarding/):OnboardingWizard.tsxowns step state + a step indicator + Back/Next/Skip/Finish footer; four step components —WorkspaceNameStep(creates + selects the first workspace, replacing the silent default),ConnectGitHubStep(required; OAuth in browser, detected on focus),WatchReposStep(skippable-with-hint),ConnectPostHogStep(optional cloud agent). - Gate (
App.tsx):AuthedApprenders<OnboardingWizard/>vs<MainLayout/>off a new persistedonboardingCompleteflag, waiting on aloadedsignal fromuseInitialDataLoadso returning users never flash the wizard. - Store (
stores/workspace.ts):onboardingCompleteflag +setOnboardingCompletesetter, hand-rolled localStorage (fastowl-onboarding-complete) like the theme/debug flags. - Data load (
hooks/useApi.ts): removed the silent "Default Workspace" auto-create; added a first-load-only migration (ref-guarded so the wizard's own workspace doesn't trip it) that marks existing users onboarded; exposedloaded. - Reuse: extracted the repo-list cache helpers into
lib/repoCache.ts(shared key with the Settings card) and the GitHub status/focus-recheck loop intohooks/useGithubConnection.ts. Workspace typechecks + lints clean.
Added a FastOwl-orchestrated merge queue: queue up a stack of PRs and they merge one-by-one, serialized per (repo, base branch), with conflicts/behind-branches auto-fixed by the same cloud run the auto-keep-mergeable watcher uses. Solves the base-branch race — merging from the app no longer means hand-merging one PR, waiting for the base to settle, then merging the next.
- Shared helpers (
services/prCloudFix.ts): extractedresolvePostHogEnvId+linkedTaskStatus+ACTIVE_STATUSESout ofprAutoMergeWatcherso both background services share one copy. - Processor (
services/mergeQueueProcessor.ts): 60 s poller, mirrors the watcher. Each tick loads queued open PRs FIFO bymerge_queued_at, groups by(workspace, repo, base), and acts only on each group's head — one head per group + the single-threadedtickingguard + a synchronous awaited merge means two same-base PRs never both merge in a tick, while distinct bases/repos proceed in parallel. Per head: refresh stale state → merge if clean (githubService.mergePullRequest, drop off the queue, promote the next) → else fire the sharedbuildPostHogPromptcloud run (which merges the base in, curing both conflicts andBEHIND), wait via the active-task guard, retry, blocked after 3 attempts.merged:false/ thrown merge → stay queued and record the error. - The race fix:
prNeedsFollowupmissesBEHIND/BLOCKED(exactly the post-merge state of every sibling PR), so aneedsUpdatecheck funnels those into the same fix path. - API + DB: migration
0021_pr_merge_queue(merge_queuedbool,merge_queued_atfor FIFO order,merge_method,merge_queue_statejsonb, partial index). NewPOST /pull-requests/:id/merge-queuetoggle; list endpoint computes 1-based per-groupposition;reconcileTerminalStatedrops closed/merged PRs off the queue. Queue state flows through PR payloads + thepull_request:updatedWS event. - Desktop: "Add to merge queue" toggle + status indicator on the PR detail-sheet header and a row action/badge (
Queued #N/Merging/Fixing/Blocked) on the GitHub list. - 13 new parameterised processor tests (real pglite DB,
mergePullRequestspied) covering clean-merge, conflict→fix, BEHIND→fix, serialization, different-base parallelism, attempt cap, re-arm,merged:false, thrown merge, no-env, and ignore-non-queued. Full backend suite green (412 tests). Workspace typechecks + lints clean.
Added an opt-in, per-PR toggle that keeps a PR mergeable unattended and indefinitely: a background watcher repeatedly fires the existing "take this PR to a clean, mergeable state" cloud run whenever the PR has a blocker (conflicts / failing required CI / changes-requested / unresolved review threads), never two at once, and keeps watching after the PR is clean so a conflict that appears days later is auto-fixed too.
- Shared helpers (
packages/shared/src/prMergeable.ts): movedprNeedsFollowup/buildIssuesSummary/buildPostHogPromptout ofGitHubPanel.tsxso the manual button and the watcher build the identical task. The prompt builder is now parameterised ({ owner, repo, number, summary }). - Watcher (
services/prAutoMergeWatcher.ts): 60 s poller overpull_requests WHERE auto_keep_mergeable AND state='open'. Per PR: refresh stale summaries (prMonitor.refreshPr), skip if a linked run is active, fold the last auto-run's outcome into an attempt counter, re-arm on a mergeable observation, then fire via the sharedcreateCloudTaskhelper. Runaway guard: pause after 3 consecutive un-mergeable auto-runs; reaching mergeable resets the counter (chosen over digest-based re-arm because the agent's own pushes change the digest). - Task creation factored into
services/taskCreate.ts(createCloudTask), shared byPOST /tasksand the watcher. - API + DB: migration
0020_pr_auto_keep_mergeable(booleanauto_keep_mergeable+auto_merge_statejsonb + partial index). NewPOST /pull-requests/:id/auto-keep-mergeable; flag + compact watcher state flow through PR payloads and thepull_request:updatedWS event. - Desktop: toggle in the PR detail-sheet header (gated on PostHog Code connected) + "Watching"/"Paused" badge on the PR list row.
- 8 new parameterised watcher tests (real pglite DB) covering the decision matrix; full backend suite green (407 tests). Workspace typechecks + lints clean.
Refocused FastOwl as a PR-management app that delegates to cloud coding agents. Ripped out the entire local-execution layer and folded PostHog Code into a pluggable provider abstraction. Landed as a series of small commits:
- Provider seam + cloud-only task queue. New
services/cloudProviders/(types,registry, genericpoller). PostHog Code wrapped ascloudProviders/posthog/provider.ts(delegates to the existingposthogCode/*executor/streamer/poller — no rewrite).taskQueuelost the idle-agent/(env,repo)-slot/git-prep machinery; it now resolves a task's cloud-marker env → provider →dispatch. NeutralCloudTaskMetadata+readCloudTaskMeta/readCloudTaskProviderhelpers in shared (legacyposthog*fields read through them). - Generic
/api/v1/cloud-providersroute + reusableensureCloudEnvironmenthelper./posthogkept as a back-compat alias for the existing Settings card. - Strip. Deleted the daemon services (registry/ws/proxy/auto-update) +
/daemon-ws, agent/agentStructured/claudeCli/ai (local Claude spawning), permission service/hook/inbox, backlog + continuousBuild, git/gitContext/gitLogService/taskCommitSnapshot/taskFileWatcher, and the agents/permission/backlog/daemon routes. Slimmedroutes/tasks.tsto the cloud surface androutes/environments.tsto list+delete.taskPullRequest→ dormant stub. Deletedpackages/daemon, the shareddaemonProtocol, and the daemon CI. Desktop: removed the local-daemon lifecycle (main IPC/menu/preload),useLocalDaemon,AddEnvironmentModal, and the Settings Environments/Continuous-Build sections. - Schema collapse (migration
0017_cloud_only): wiped tasks, droppedagents/backlog_*tables, slimmedenvironmentsto a secret-free marker, droppedtasks.assigned_agent_id/terminal_output. - CLI/MCP: dropped backlog commands/tools +
mark_ready_for_review.
Full workspace typechecks; 365 backend tests pass. Design + remaining work (Codex Cloud, Claude Routines) in CLOUD_PROVIDERS.md. Note: the daemon-everywhere / continuous-build roadmaps are now superseded.
Starting a task from a PR row ("Get PR mergeable" / "Address PR") now associates the task with that pull_requests row, and the GitHub list shows a status-aware badge on the row that deep-links to the task.
- Linking.
CreateTaskRequestgains optionalpullRequestId. NewattachTaskToPullRequestRow()inprCache.tssetstask_idby row id (workspace-scoped; overwrites any prior link so the row tracks the active fix task — the reverse oflinkTaskToPullRequest, which is sticky for PRs a task opens) and emitspull_request:updated. The tasksPOSTroute links best-effort (fire-and-forget) after insert. - Indicator.
PRTableRowreads the linked task's live status from the workspace store (task:statuskeeps it current). Shows "Working" (spinner) whilepending/queued/in_progress, "Review" (amber) whileawaiting_review, and nothing oncecompleted/failed/cancelled— matching "indicator while running, gone when complete". Unknown/unloaded status falls back to a plain "Task" badge so the link isn't lost. Clicking opens the task (selectTask+ Queue panel). - Button gating. The start-task buttons suppress while a task is active on the row (create-task hidden via
!taskActive; "Get PR mergeable" disabled with a clearer tooltip) so you can't double-launch. Both create handlers passpullRequestIdand optimistically set the row'staskIdso the badge appears instantly. Thepull_request:updatedhandler now patchestaskId. - Tests. 4 new
attachTaskToPullRequestRowcases (set+emit, overwrite, unknown-id no-op, cross-workspace refusal). prCache suite green (32 tests); typecheck + lint clean across shared/backend/desktop.
Switching PRs felt laggy because PRDetailSheet blocked on a full GET /pull-requests/:id round-trip every time. Now the list passes the already-loaded row (seedRow) into the panel; the panel renders that cached summary instantly (title, branch, status pill, check rollup) and refreshes the live detail (reviews/files/check rows/body) in place.
viewselection (useMemo): the fetcheddataonce it matches the currentpullRequestId, else theseedRowwhile the fetch is in flight. An id guard stops the previous PR's detail flashing during a switch (and thepull_request:updatedWS patch now also guardsprev.row.id === p.id).- Minimal spinner: a small
Loader2next to the title whiledetailPending(current PR's detail fetch unresolved), instead of a full-panel "Loading…". Threaded intoOverviewTab("Loading description…") andChecksTab("Loading checks…") so they show a spinner rather than the empty/"unavailable"/GitHub-fallback states while loading. The "Detail fetch unavailable" note only shows once the fetch resolves empty. - Esc closes the panel (
keydownlistener, both layouts). QueuePanel's overlay usage passes noseedRow, so it keeps the original full-loading behaviour — unchanged except it now also closes on Esc.
The Session 28 "shift the list left" margin hack didn't actually fix switching — marginRight: min(42rem, 100%) collapses the list to zero width on any content area ≤ 42rem (common at typical window sizes), so rows still weren't clickable and the panel never switched. Replaced it with a real split layout: on the GitHub page the PRDetailSheet now renders as an in-flow flex sibling beside the list (new layout="inline" prop) instead of a fixed overlay, so the list keeps flex-1 width and stays clickable; clicking another PR changes selectedId and the already-mounted sheet refetches. QueuePanel keeps the default layout="overlay" (unchanged). The sheet's container class switches between h-full shrink-0 (inline) and the original fixed inset-y-0 right-0 z-40 shadow-2xl (overlay).
Ripped out the bespoke renderMarkdownish parser (apps/desktop/src/renderer/lib/markdown.tsx) and rebuilt it on react-markdown + remark-gfm + rehype-raw + rehype-sanitize. The hand-rolled parser kept hitting gaps on real PR/review content (tables, then <details> — patched twice); the library handles GFM (tables, task lists, strikethrough, autolinks) and raw HTML for free, sanitized.
- Same public API.
renderMarkdownish(text, variant)is now a thin shim over a new<Markdown text variant />component, so all four call sites (AgentConversationfeed,PRDetailSheetsurface ×3) are unchanged. Thefeed/surfacepalette split is preserved via a per-variantcomponentsmap (links, code/pre, headings, lists, blockquote, hr, tables, details/summary, img). - Safety.
rehype-raw→rehype-sanitize(extendeddefaultSchemato allow<details>/<summary open>) so untrusted GitHub HTML renders without XSS. - Jest + ESM. react-markdown's plugin tree is pure ESM and breaks ts-jest's CommonJS transform — followed the repo's existing pattern (the
@pierre/diffs/reactmock) and stubbedreact-markdown/remark-gfm/rehype-raw/rehype-sanitizeviamoduleNameMapper+.erb/mocks/*. Markdown-rendering correctness now relies on react-markdown's upstream tests; our jest test is a wrapper smoke test (the old DOM-level table/details tests were removed since the renderer is mocked). Verified the real bundle with a productionbuild:renderer(webpack resolves the ESM cleanly).
Four UX improvements to the PR detail side-panel (PRDetailSheet) and the GitHub list:
- Checks tab — tile filters. The Passed/Failed/Running/Skipped rollup tiles are now toggle buttons (
CheckCountTile→<button>witharia-pressed+ ring highlight). Clicking one filters the per-check list to that state; clicking again clears. Tiles with a zero count are disabled. - Checks tab — failed first. The per-check list is sorted by a fixed state rank (
failure → in_progress → pending → success → skipped, unknown states last) so anything needing attention sits at the top. - Reviews tab — full GitHub-like experience. New backend endpoint
GET /pull-requests/:id/reviews(fetchPRReviewDetail/decodeReviewDetailingithubGraphql.ts) does one GraphQL round-trip for every submitted review (with body), every inline review thread (grouped, with diff hunk + resolved/outdated state), and the top-level conversation comments — all with author avatars and markdown bodies. The tab fetches this on open and renders Reviews / Inline comments (unresolved-first, with an unresolved count) / Conversation sections. Replaced the old terseActivityListlink-outs. - PR list — switch the open panel. The detail panel overlays the right edge; the GitHub list now shifts left (
marginRight: min(42rem, 100%)) while a PR is selected, so every row stays visible and clicking another PR switches the panel.
New renderer API types: PRReviewDetail / PRReviewThread / PRReviewThreadComment / PRReviewDetailReview / PRConversationComment + pullRequests.reviews(id). Five new decodeReviewDetail tests (filtering, sort order, diff-hunk extraction). Typecheck + lint clean, backend suite green.
Added a one-click way to dispatch a PostHog Code cloud run that takes a PR to a clean, mergeable state (resolve every review comment, get CI green, resolve conflicts — looping until all three hold). Modelled on the task-script/pr_review_followup/create_pr_tasks.py prompt.
- Unresolved review thread count surfaced in the GitHub list. Extended the batched GraphQL fetch (
services/githubGraphql.ts) with an aliasedunresolvedThreads: reviewThreads(first: 100) { nodes { isResolved } }, counting unresolved into a newPRSummary.unresolvedReviewThreads. Persisted throughprCache(summaryToJsonb/rowToSummary/ placeholder) and exposed on the rendererPRSummaryShape(optional, for rows cached before the field existed). Rendered as an amberMessageSquare Nbadge next to the Status pill inGitHubPanel. - The button sits in the row action cluster, immediately left of the copy-branch button. Only rendered when PostHog Code is connected for the workspace and the PR is open; disabled (greyed) unless the PR actually has something to fix —
prNeedsFollowup()= merge conflicts ∥ changes-requested ∥ failing checks ∥unresolvedReviewThreads > 0. - Dispatch path: builds the full follow-up prompt (
buildPostHogPrompt) and creates apr_responsetask withassignedEnvironmentId= the auto-provisionedposthog_codeenv, which the task queue already routes todispatchTaskToPostHogCode. Then jumps to the new task. PostHog status is fetched viaapi.posthog.getStatus; the cloud env id comes from the workspace store. - Tests: two new decode cases in
githubGraphql.test.ts(counts unresolved; defaults to 0 when absent); updated the threePRSummarytest builders. Full backend suite green (117).
Added PostHog Code as a new way to run tasks — a posthog_code environment type that delegates the entire agent loop to PostHog's sandboxed cloud runners instead of driving Claude locally over a daemon. Landed in two commits (backend, then desktop UI).
The key insight: PostHog Code is a delegation provider, not a daemon transport. FastOwl's existing model drives the agent itself (spawns claude -p, parses JSONL, branches git, auto-commits → awaiting_review). PostHog Code instead owns the whole loop on its own machine (clones repo, runs agent, commits, pushes, opens a PR). So FastOwl's role becomes create → poll → ingest the PR. It's a new execution provider at the task-queue level, not a new entry in the daemon stream_spawn/git wire protocol.
API used ({host}/api/projects/{projectId}, Authorization: Bearer <personal key>): POST /tasks/ (create), POST /tasks/{id}/run/ ({mode:'background', runtime_adapter, model}), GET /tasks/{id}/ → latest_run.{status, branch, output, error_message, log_url}. Run status enum not_started|queued|in_progress|completed|failed|cancelled. PostHog auto-detects the opened PR URL and attaches it to the task, so we scan the task/run JSON for the first github.com/.../pull/N.
- Backend (
services/posthogCode/):client.ts(typed REST),credentials.ts(per-workspace key stored encrypted on the existingposthogintegration row, reusingtokenCrypto),executor.ts(create remote task + start run, stashposthogTaskId/posthogRunIdontask.metadata, idempotent),poller.ts(10s reconcile of in-flight runs →awaiting_reviewwhen a PR opened, elsecompleted;failed/cancelled→failed; links the PR vialinkTaskToPullRequestso it flows into the existing PR monitor + inbox). - Task queue fork:
posthog_codetasks bypass the idle-agent /(env,repo)slot / concurrency machinery entirely (no working-tree contention in the cloud — concurrency control dropped by design) and are excluded from stuck-recovery (they have no FastOwl agent). Cloud envs are opt-in: excluded from the "any connected env" default pick. - Auth model: key + project id live per workspace (the
posthogintegration row); the env is a secret-free marker. Created/booted asconnectedwith no pairing. - Routes:
/posthogworkspace-credential CRUD — key is write-only over the API and validated (ping) before persist. - Desktop: Add-Environment "PostHog Code (cloud)" option; Settings → Integrations PostHog Code card; Create-Task runtime/model overrides when a cloud env is picked; a cloud-run banner (status + log link) in the task detail. PR pill renders from
metadata.pullRequestonce the poller links it.
Open follow-ups: confirm the exact PR-URL field against a live response (currently regex-scans the whole task/run JSON); optional live transcript via the GET …/runs/{id}/stream/ SSE endpoint (left a clean seam, not built — decision was status+final-result only).
Test note: full backend suite shows ~22 PGlite is closed cleanup failures under the parallel run (pre-existing infra flakiness); all touched files pass clean in isolation (taskQueueProcess 9/9, environments+tasks+environmentService 65/65).
Continuation of the Conductor-parity work, focused on the GitHub page (GitHubPanel.tsx) after a full assessment of its bugs/gaps (#1–#9). Landed in four commits:
- Quick fixes + table polish (#1 #2 #3 #9). Refresh now triggers a real GitHub force-poll (
repositories.forcePoll()) then re-reads the cache — previously it only re-read the local DB, so "Refresh" never actually hit GitHub. Added a "Connect GitHub" empty state (viagithub.getStatus) so a disconnected workspace no longer shows the same misleading "no PRs match" message as a connected-but-empty one. Sortable Updated column, live counts on the Open / Needs-attention pills, keyboard-navigable rows, a Task badge that deep-links to its task, and fixed the stale tabs doc comment. - Row actions (#8). Each PR row reveals on hover a squash-merge action (confirm-gated, shown only when GitHub reports the PR mergeable, reusing
pullRequests.merge) and a create-task action that spins up apr_responsetask for the PR and jumps to it. - Unread indicators (#7). A blue dot + count on PRs with unread activity. Derived with zero schema change from unread
inbox_itemslinked to a PR via the existingdata->>'prUrl'jsonb key (there's no inbox→PR FK). The list route (GET /pull-requests) now returnsunreadCountper row via one grouped query; opening a PR clears the dot and flips its inbox items to read via newPOST /pull-requests/:id/seen; aninbox:newWS event bumps the dot live. +4 route tests. - Review-requested PRs (#4). The monitor previously watched only PRs authored by the connected user. It now also watches PRs where the user is a requested reviewer (
requested_reviewersfrom the REST list), persisting a newreview_requestedboolean column (migration0014).pollRepowidens the filter and threads the flag throughupsertFromBatchResult→upsertRow.sweepClosedgained a guard: a review-requested PR drops off the watch list the moment the user reviews it but stays OPEN on GitHub, so we no longer wrongly mark still-open PRs closed. The list route gained arelationship=authored|review_requested|allfilter, the page a Mine/Review/All pill group, and review-requested rows a purple "Review" badge. +6 tests (3 monitor, 1 route filter, plus sweep-guard + flag assertions).
Migration note: drizzle-kit generate is currently broken by a pre-existing snapshot collision in meta/, unrelated to this change — 0014_pr_review_requested.sql + the _journal.json entry were hand-written to match convention (the runtime postgres migrator only reads the journal + .sql files).
Recovered session: this work resumed a prior session (03099785…) that crashed mid-research on a thinking-block API error before writing any code.
Kicked off after comparing the task view against Conductor (conductor.build). Goal: close the "feels buggy / lower quality" gap. Full assessment + remaining backlog in docs/QUALITY_PARITY.md. Landed in four commits:
- Feed performance (the main "sluggish" cause). Every stream-json
task:eventdid an O(n) dedup + O(n log n) re-sort of the whole transcript AND triggered a full React re-render — dozens of times a second during a turn. Now:task:eventis buffered per task inuseApi.tsand flushed once per frame (setTimeout(40ms)so it survives backgrounding); append is the hot path, re-sort only on a detected out-of-order seq; drains on teardown.BlockViewinAgentConversation.tsxis nowReact.memo'd with a cheap render-affecting signature (blockSignature) so a transcript update only re-renders the live streaming tail + any mutating permission card, not every settled block. - PR file diffs in-app.
GET /pull-requests/:id/filesexposes the previously-deadgithubService.getPRFiles. ThePRDetailSheetFiles tab now fetches the list, shows a changed-files summary (count + total +/-), and renders each file's diff inline via@pierre/diffsPatchDiff(the same viewer the task Files tab uses) in an expandable accordion. GitHub's hunks-onlypatchis wrapped in a synthesiseddiff --git/---/+++header (toUnifiedDiff) so added/removed files render as pure inserts/deletes. +4 route tests. - In-app merge + per-check breakdown.
POST /pull-requests/:id/merge(squash default) wrapsgithubService.mergePullRequestand force-refetches so the row flips to merged immediately. The sheet shows a green Merge button only for an open, mergeable PR, behind a two-step confirm. This deliberately reverses the Phase-7 decision to make all PR writes deep-links — merge is now the one in-app write path; review/comment composition still deep-links out. Per-check rows (checkContexts: name, normalized state, link) are now exposed on the livePRSummarydetail fetch (data was already normalized for the rollup counts; not persisted to the cached summary, so no DB bloat) and rendered as individual rows in the Checks tab. +1 graphql decode assertion. - Richer markdown in the feed.
renderMarkdownish(still dependency-free) now covers headings, bullet/numbered lists, blockquotes, horizontal rules, and an inline parser for**bold**,*italic*,[links](url), and`code`. Unrecognised input still falls through as a plain paragraph.
Already-fixed-in-code backlog items confirmed during the sweep: the "duplicate Stop button" (QueuePanel intentionally renders none — TaskTerminal owns Finish/Abort) and the "non-functional inbox 3-dot menu" (fully wires markRead/archive/delete) were both already resolved.
Deferred (need backend contract work — see QUALITY_PARITY.md): composer model picker + attachments (adding non-functional UI would reintroduce the placeholder feeling we're removing), a true simultaneous 3-pane layout (the PR sheet overlay already gives task→PR continuity), and desktop component/E2E test coverage.
Replaces the per-PR-REST-fan-out poller + the lone PRListWidget with a batched-GraphQL DB-as-cache pipeline plus a real GitHub page and a task-screen status pill. Inspired by supacode's batchPullRequests + statusCheckRollup design (see docs/SUPACODE_COMPARISON.md).
- Phase 1 — schema + GraphQL helper.
pull_requeststable (DB-as-cache: minimal jsonb summary + cursors, no per-check rows, no raw payload).services/githubGraphql.tswithbatchPullRequests(chunks of 25, up to 3 concurrent queries),normalizeCheckState(collapses GitHub's three-axis status/conclusion/state into one verdict),computeBlockingReason(mergeable + mergeStateStatus + reviewDecision + checks.failed → mergeable | merge_conflicts | changes_requested | checks_failed | blocked | unknown),computeCheckDigest(hash of head_sha + sorted check states for cheap "checks changed?" detection). - Phase 2 — prCache + cursor deltas.
services/prCache.tswithgetOrFetchPRSummary(TTL hit / GraphQL fetch on miss),forceFetchAndUpsert(always GraphQL),upsertFromBatchResult(caller already has summary), and purecomputePRDeltas(walks freshest-first arrays up to the persisted cursor; avoids re-emitting CI-failure on a still-failing PR via digest scan). Rewroteservices/prMonitor.ts: removed the in-memory state map (lost on every restart, the source of "unread events vanishing on deploy"), per-tick REST list of user-authored open PRs filtered bycurrentUserLogin, batch-fetch stale ones via GraphQL, sweep-closed for rows that disappear from the open list. Same four inbox types preserved. - Phase 3 — read routes + WS. Four
/api/v1/pull-requestsendpoints (list / get / refresh / focus). Newpull_request:updatedWS event fires on every upsert. Detail endpoint returns the persisted row + a fresh GraphQL fetch for recentReviews/comments (cache fallback when GraphQL is down). - Phase 4 — task-screen pill.
widgets/PRStatusPill.tsx(blocking-reason variants + 5-segment check rollup bar),widgets/PRDetailSheet.tsx(slide-in side panel — skeleton in this phase, tabs in Phase 5). Wired intoQueuePaneltask header.prCache.linkTaskToPullRequestseeds the row at PR-open time with task_id (race-safe), so the pill resolves the linked PR viatask.metadata.pullRequest.id. - Phase 5 — GitHub page rebuild. Replaces
PRListWidget(deleted in Phase 7) with a real table + filter bar (state pills / repo dropdown / needs-attention / search). Side-sheet got Overview / Checks / Reviews / Files tabs. WS-driven row patching (no full refetch on every event). - Phase 6 — adaptive polling.
services/prFocus.ts— in-memory focus + 5s post-refresh cooldown registry.prMonitor.filterStaleconsultsttlForper row (30s focused / 60s unfocused / Number.MAX_SAFE_INTEGER while in cooldown). Poll tick dropped to 30s. Desktop declares focus from both surfaces (task screen pill + GitHub-page detail sheet). - Phase 7 — cleanup. Deleted
PRListWidget+PRDetailModal+ everyapi.github.*PR-management method (list/get/files/checks/create/merge/review/comment) + the matching backend routes.githubService.createPullRequeststays (used byopenPullRequestForTask). AlignedInboxItemTypewith what the backend emits (pr_review,pr_comment,ci_failure,pr_ready) — was previouslypr_ci_failure/pr_ready_to_mergeand missingpr_commententirely. Added the missingpr_commenticon to InboxPanel.
Tests: 147 across the new surface (38 GraphQL helpers, 22 prCache + 3 linkTaskToPullRequest, 8 prFocus, 18 prMonitor poll, 18 routes, 6 schema, plus 12 repo CRUD + 12 taskPullRequest + 10 routes/github survivors).
Re-litigates the "task hits awaiting_review with uncommitted files in the working tree" symptom. Forensic on the prod DB found two real shapes: (a) metadata.autoCommit getting silently overwritten when the commit DID happen — autoCommitAndSnapshot's persist racing with the fire-and-forget void recordGitCommand writes from inner gitService calls, both doing un-serialized SELECT metadata → modify → UPDATE, last writer wins; (b) commitAll reporting no-changes even on a dirty working tree, with no signal in the UI and the task auto-advancing to awaiting_review where Reject would discard the work.
-
Per-task metadata mutex (
services/taskMetadataMutex.ts, new):patchTaskMetadata(taskId, patch)serializes every metadata RMW per-task.gitLogService.recordGitCommand,taskCommitSnapshot.persistAutoCommitStatus,writeFinalFilesSnapshot,taskPullRequest.openPullRequestForTask(success + error paths), andtaskQueuerollback'slastScheduleErrorwriter all route through the same chain. Atomic SQL||jsonb merges (agent.ts session_id_captured + runtime tag) stay as-is — they're already safe. -
Hardened
autoCommitAndSnapshot(services/taskCommitSnapshot.ts): pre-flightgetPorcelainStatussnapshot, post-commit verification, branch-ahead check via newgitService.commitsAhead. Result type grows anadvanceOk: booleancontract — callers MUST honour it. New failure modes:dirty-after-commit(loud red banner, working tree still dirty afteradd -A+commit; most likely cause is wrong cwd or daemon misroute),no-changes-no-commits(clean tree but branch has 0 commits ahead of base — the agent didn't actually do anything),wrong-branch(couldn't switch HEAD onto the task branch).no-changessplit intono-changes-prior-commits(advanceOk: true — Claude already committed) vsno-changes-no-commits(advanceOk: false). Every outcome persists a structuredmetadata.autoCommitrecord with reason, error message, and a porcelain preview. -
Block the awaiting_review transition on hard failure:
agent.handleStructuredExit,agent.maybeAutoFinishAgentTask, andPOST /tasks/:id/ready-for-reviewall checkresult.advanceOk. On false they leave the task inin_progress(not failed — failed exposes Reject which destroys the dirty tree) and emittask:statusso the desktop re-renders. The route returns 409 with the reason instead of silently flipping to awaiting_review. -
UI surface (
QueuePanel.tsx): three new banners above the tabs. Loud red onin_progress + advanceOk=falsewith a "Retry auto-commit" button (re-runs/ready-for-review, the same code path); subtle green onawaiting_review + committedshowing sha + message; subtle amber onawaiting_review + no-changes-prior-commits("branch already had commits — nothing new to add"). Hooks up to existinguseTaskActions().readyForReview. -
Tests:
helperServicesautoCommit suite rewritten for the new shape —advanceOk, the four new failure modes (dirty-after-commit, no-changes-no-commits, wrong-branch, error), separate prior-commits-vs-no-commits assertions for the no-changes split.tasksLifecycleadds the 409 path onadvanceOk=falseand asserts the task staysin_progress.agentLifecyclemocks updated to the new result shape. All 572 backend tests pass; typecheck + lint clean.
Moves the auto-commit + file-diff snapshot from /approve to the in_progress → awaiting_review transition. Motivation: the Files tab used to go blank once the env disconnected (because getChangedFiles is a live git query), and the working tree stayed dirty until the user approved, blocking back-to-back tasks on the same repo. With the snapshot persisted on the transition, the Files tab survives env offline, and the approve button shifts role — it's now "Create PR", the terminal step.
-
autoCommitAndSnapshot(taskId)(services/taskCommitSnapshot.ts, new): checks out the task branch, regenerates the commit message viagenerateCommitMessage, runscommitAll, then persists a{files[], perFileDiffs}snapshot ontask.metadata.finalFiles(per-file diff capped at 50 k chars). Same shape the old approve path wrote; overwrites on each call so follow-up rounds produce a fresh cumulative snapshot. Non-fatal on empty-changeset, env offline, or any git error — callers always transition. -
Wired into all three
in_progress → awaiting_reviewsites:POST /tasks/:id/ready-for-review,AgentService.handleStructuredExit(one-shot clean exit), andAgentService.maybeAutoFinishAgentTask(interactive turn-complete auto-finish). Replaced the oldprefetchCommitMessagefire-and-forget at each site;services/commitMessagePrefetch.tsand theGET /tasks/:id/proposed-commit-messageroute are gone. -
/approveslimmed to push + PR + completed (routes/tasks.ts): drops commit/snapshot logic (done earlier on the transition). Still callsautoCommitAndSnapshotas a safety net on entry — covers pre-refactor tasks, env-was-offline-at-transition tasks, and manual tweaks made inawaiting_review. Dirty-tree check remains as a post-push guard. -
State-aware Files-tab routes (
GET /tasks/:id/diff/files,/diff/file):completed→ snapshot only.awaiting_review→ try live git, fall back tometadata.finalFilesif the env's offline or git throws. Everything else (in_progress etc.) → live only (no fallback, to avoid showing stale snapshots from a previous round). Newsource: 'live' | 'cache'field on the response;useTaskFilessurfaces it so the UI can indicate offline state later. -
UI:
QueuePanel.tsx"Commit & push" button → "Create PR" (one-click, usesGitPullRequesticon).ApproveTaskModal.tsxdeleted — commit message isn't user-editable anymore since the commit already happened.api.tasks.approvedrops itscommitMessageparam;proposeCommitMessageis gone. -
Tests: helperServices — new
autoCommitAndSnapshotsuite covering all fivereasonbranches, cumulative-snapshot overwrite on re-run, and non-throwing error surface. routes/tasks — new awaiting_review cache-fallback test, in_progress-doesn't-fall-back-to-stale-cache test,source: 'live'assertion on the live path. routes/tasksLifecycle — approve tests rewritten for push+PR semantics (no more commit exit-code scripting), new ready-for-review assertion that autoCommit fires, empty-changeset still transitions. All 564 backend + 98 daemon tests pass.
Closes the loop on Phase 14: tasks now own their branch end-to-end, from a synced base at start through commit + push on approve. Landed together so each piece makes sense alongside the next — a partial slice here would leave tasks in a worse state than before.
-
prepareTaskBranchwith base sync (gitService.ts): one entry point for "start a task on this repo" — fetches the default branch, fast-forwards to origin, then createsfastowl/<id>-<slug>off it. Refuses to proceed if the tree is dirty (the slot guard should have prevented it) or if the base has diverged from origin (fails loud rather than branching off stale state). Wired into bothPOST /tasks/:id/startandtaskQueue.processQueue— previously the scheduler's auto-pick path skipped branch setup entirely and edited whatever happened to be checked out. -
(env, repo) single-slot guard (
findTaskHoldingEnvRepoSlotintaskQueue.ts): anin_progressorawaiting_reviewtask holds the working tree for its(assignedEnvironmentId, repositoryId)pair. Scheduler skips queued tasks whose pair is held;/startreturns 409. Awaiting-review keeps the slot because the working tree is still dirty with its work — approve or reject frees it. -
/approve→ commit + push (routes/tasks.ts): newgitService.commitAll(staged via base64→stdin for arbitrary messages, no shell-escape concerns) +pushBranch+getDiffStat. Default commit message comes fromgenerateCommitMessageinservices/ai.ts— Claude Haiku 4.5, same pattern asgenerateTaskTitle, with the diff truncated to 6k chars. User can override via the approve modal's textarea or POST acommitMessagefield. On push success, check out base andgit branch -D <task branch>so the slot is free for the next task; remote branch stays. -
ApproveTaskModal(components/modals/): opens on Approve click; fetches the proposed message fromGET /tasks/:id/proposed-commit-message, shows it in an editable textarea, submitscommitMessagewith the approve call. Shift-click bypasses the modal for users who trust the LLM. -
/reject→ stash to backup + reset tree: newgitService.stashToBackupRefcaptures the current working tree (viagit stash create+update-ref) intorefs/fastowl/rejected/<taskId>, thenresetToBasedoescheckout -f/reset --hard origin/<base>/clean -fd. The task goes back toqueuedwithbranchcleared so retry gets a freshprepareTaskBranch. Rejected work is recoverable withgit checkout -b <name> refs/fastowl/rejected/<taskId>. -
Live file-change view (Files tab). New
taskFileWatcherservice subscribes toagentStructuredService'seventstream, watches fortool_useblocks in{Edit, Write, MultiEdit, NotebookEdit, Bash}, debounces 500ms, runsgit diff --numstat+ls-files --otherson the task's env, and broadcasts a newtask:files_changedWS event. New endpointsGET /:id/diff/filesand/diff/file?path=...back the desktop UI. Terminal/Files tabs in the running-task view; Files tab replaces the inline diff in awaiting_review. Per-file diff viewer includes an in-flight-write pulse dot derived from unmatchedtool_useevents, and caps rendering at 2k lines. OldTaskDiff.tsxremoved —TaskFilesPanelsupersedes it.
Explicitly deferred: git worktrees (would drop the single-slot constraint but each worktree needs its own node_modules — monorepo pain), PR creation button, resume-task-on-different-env. See Phase 14.6/14.7 in ROADMAP.
One-session refactor that collapses local/ssh/daemon/coder env types into local | remote with a single transport: every environment is backed by a @talyn/daemon process dialling the backend over WebSocket. The immediate trigger: backend restart was SIGPIPE-killing local tasks because the child's stdin was piped directly to the backend process. The daemon now owns those pipes, so backend deploys don't take down in-flight work.
Eight slices, each a landable git push to main. Design doc: DAEMON_EVERYWHERE.md — kept as the live task list throughout.
-
Slice 1 — single-file daemon binary (
8b26059):packages/daemon/scripts/build-binary.sh+.github/workflows/build-daemon-binaries.ymlcross-compilebun build --compileto five targets (darwin-arm64/x64, linux-x64/arm64, windows-x64) on one Ubuntu runner.ws+ workspace imports work underbun --compile, verified by the smoke test that runs the linux binary with no args and checks the config-resolution error message. -
Slice 2 — bundle binary in the Electron
.app(4518746): platform-specificextraResourcesentries inapps/desktop/package.jsonusing${arch}macros; each packaged build pulls the matching binary frompackages/daemon/dist/fastowl-daemon-*and drops it atContents/Resources/daemon/fastowl-daemon. Rootnpm run packagerunsbuild:binary:all -w @talyn/daemonbefore invoking electron-builder.publish.ymlgains asetup-bunstep. -
Slice 3 — localDaemon install module (
81bfb4a): newapps/desktop/src/main/localDaemon.ts. macOS writes~/Library/LaunchAgents/com.fastowl.daemon.plist(KeepAlive=true,RunAtLoad=true, logs to~/Library/Logs/FastOwl/) and callslaunchctl bootstrap gui/<uid>—bootoutfirst so re-install is idempotent. Linux writes~/.config/systemd/user/fastowl-daemon.servicewithRestart=alwaysand runssystemctl --user daemon-reload && enable --now. Windows deferred. Dev mode spawnstsx packages/daemon/src/index.tsunder Electron's lifetime for fast iteration. -
Slice 4 — auto-pair on first launch (
806bfef+ fixes): IPC handlers (daemon:is-paired,daemon:host-label,daemon:configure-and-start,daemon:ensure-running) inmain.ts; preload bridge; rendereruseLocalDaemon()hook inAuthedApp. Flow: after login, the hook creates a "This Mac ()" env, mints a pairing token, hands it to main, main writes~/.fastowl/daemon.json+ spawns/installs the daemon. Follow-ups landed same session: (a) daemon acceptspairingTokenfrom the config file as a fallback (141ccd3); (b)wss/daemonWssrouting fixed — the{server, path: '/ws'}auto-handler was aborting every non-/wsupgrade with 400, so the local daemon could never connect. Both WSS's are nownoServer: true, dispatched by path in one handler. (c)useLocalDaemonlooks up an existing "This Mac" env before creating, so failed pairs don't accumulate orphans (6677b00). (d) Local daemon env defaults toautonomousBypassPermissions: false(the override is for remote VMs) (ad4ed76). -
Slice 5 — collapse env types (
7907b35):EnvironmentType = 'local' | 'remote'. Migration0009_daemon_everywhere.sqlrewrites existingdaemon-with-"This Mac" name →local, otherdaemon→remote, deletes stalessh/coderrows.services/environment.tsrewritten ~200 LOC shorter — no switch onenv.type, everything routes throughdaemonRegistry. Deleted:services/ssh.ts,services/daemonInstaller.ts, the SSH auto-install route,docs/SSH_VM_SETUP.md,ssh2+@types/ssh2deps.AddEnvironmentModalsimplified from 626 → 210 LOC (one flow: name → pair → poll). SettingsPanel branches onlocal/remote. Dockerfile drops itsnpm rebuild ssh2step — backend now has zero native deps. -
Slice 6 — session survival across backend restart (
0cb795a): the payoff. Daemon hello now carriesactiveSessions: [{sessionId, pid, startedAt}]. BackenddaemonRegistrystoresliveSessionIdsper connected daemon, exposesisSessionLive()+connectedEnvironmentIds(), emitsdaemon:connected.agent.cleanupStaleAgentsrewritten from "blanket-fail on boot" to a 60s-grace reconcile sweep with a fast path: once every expected env's daemon has dialled in, sweep immediately. Follow-up (f93267e):agentStructuredService.resumeRun()rehydrates per-run state fromtasks.transcript+ re-subscribes to session events, so surviving agents produce live UI events — not just stay alive. Final polish (36fcc04):permission_tokencolumn on agents +permissionService.rehydrateRun(); a child mid-PreToolUse at restart continues to authenticate. -
Slice 7 — lifecycle surface + uninstall flow (
02c1b5c): Settings → Environments "This Mac" card shows launchd install + PID status, refreshes every 5s, has a Restart button. App menu → Daemon submenu with Restart + "Uninstall FastOwl daemon and quit…" (confirm dialog + full wipe).scripts/fastowl-uninstall.shbundled via extraResources — usable from the.appor the repo for users who deleted the app before uninstalling. -
Slice 8 — tests + docs: new
agentReconcile.test.tscovering the Slice 6 sweep (survivor kept / non-survivor failed /isSessionLiveround-trip). ExistingdaemonRegistry.test.tsfixture updated for the newliveSessionIdsrequired field.ARCHITECTURE.mdandCLAUDE.mdCore Concepts rewritten around the two-type, one-transport model.ROADMAP.mdmarks Phase 18.5 done; SSH + Coder types struck from Phase 1.2's env-type list.
What this buys:
- Backend restart / deploy no longer kills running tasks. Verified manually (
pkill -9on the dev backend; task stays in_progress; output resumes). Single biggest day-to-day reliability win. - One execution path. Every
env.type === '…'switch across backend/desktop/cli/mcp is gone. New features touch one surface. - Backend has zero native deps.
ssh2+node-ptyboth retired — Dockerfile is lighter; CI stops hitting native-build flakiness. - Local-daemon UX: zero-click pairing, OS-service lifetime, restart + uninstall from the menu.
Known limits: session output during the disconnect window is still dropped (no ring buffer yet — tracked in DAEMON_EVERYWHERE.md as a Slice 6 gap). Daemon-process crash still kills its children (Electron crash → local-daemon crash → task dies); rarer than backend deploys, handled by launchd's KeepAlive=true auto-restart.
The big cleanup pass. Structured renderer now covers every env type — local (in-process spawn), daemon (new stream_spawn wire op), SSH (ssh2 exec channel with pty: false) — and the PTY path is gone. Landed as three commits (4a daemon, 4b SSH, 4c deletion) so each step was revertable on its own.
-
Slice 4a — daemon streaming (
22a4759): newstream_spawn+close_stream_inputops +session.stderrevent in the daemon wire protocol.packages/daemon/src/executor.tsgains a non-PTYstreamSpawnthatchild_process.spawns the binary with plain pipes; stdout flows back assession.data, stderr assession.stderr, exit assession.close.environmentServicegrowsspawnStreaming+closeStreamInputthat route to local (in-process) or daemon (wire op) based on env type.agentStructuredrefactored to go through env service as the transport — no more directchild_process.spawn; start() is now async and takesenvironmentId. Dispatcher drops the local-only gate. -
Slice 4b — SSH streaming (
7161994):sshServicegainsexecStream/writeToStream/closeStreamInput/killStream/hasStream. Uses ssh2's exec channel withpty: falseso stream-json output isn't wrapped in TTY escapes. Env service forwardsstream:*events under the samesession:*names. Dispatcher drops the env-type gate entirely — structured now works onlocal,daemon, andssh. Environment routes drop the "local only" guard. -
Slice 4c — PTY deletion:
- agent.ts collapsed to a single structured path. Removed:
STATUS_PATTERNS,detectStatusFromOutput,analyzeOutput(regex-based PTY output scanning),handleSessionData+handleSessionClose(PTY-only DB writers foragents.terminal_output/tasks.terminal_output),buildFastOwlEnvPrefix+shellQuote(PTY-only shell-quote helpers), the whole PTY dispatcher branch instartAgent.startAgentis now a thin wrapper over the structured path; no morestartStructuredAgentsplit. - environment.ts: removed
spawnInteractive+spawnLocalInteractive+localPTYsmap +node-ptyimport. OnlylocalStreams+localProcessessurvive. - ssh.ts: removed
createPTY/writeToPTY/closePTY/resizePTY/PTYSession/ptySessions/pty:data+pty:closeevents. Streaming-exec is the only path. - daemon/executor.ts: removed
spawnInteractive,ptySessions, and thenode-ptydep. - daemon/wsClient.ts: dropped the
spawn_interactivecase from dispatch. - shared/daemonProtocol.ts: removed
SpawnInteractiveRequestfrom the request union. - Desktop: removed
XTerm.tsx,@xterm/xterm+@xterm/addon-fit+@xterm/addon-web-linksdeps,.erb/mocks/xtermMock.js+ matching Jest moduleNameMapper.TaskTerminal.tsxalways rendersAgentConversation; theisStructuredTaskcheck is gone.TerminalHistory.tsxstill falls back to a plain<pre>for historicalterminal_outputrows that pre-date the structured renderer — those can't be back-filled, so they stay readable as legacy data. - Tests: deleted
agent.envPrefix.test.ts+agent.statusDetection.test.ts(tested functions that no longer exist).fakeEnvironment.tsrewritten to patchspawnStreaming+closeStreamInputinstead ofspawnInteractive.gitServicerefactored from the PTY-session-event-listener pattern toenvironmentService.exec()(one-shot). Full suite: 94 tests passing in ~40s. - git.ts:
executeGitCommandsimplified — dropped thespawnInteractive+ session-event-listener + 5s timeout dance, now just callsenvironmentService.exec()and returns stdout. - Schema / migration:
0008_default_structured_renderer.sqlflips the default + back-fills existing rows from'pty'to'structured'. The column is kept (not dropped) so rollback stays possible — but no code path reads'pty'anymore. - Infra: Dockerfile +
scripts/install-daemon.shno longer installbuild-essential/python3for node-pty's native build.ssh2is the last remaining native dep; its prebuilds cover linux-x64 cleanly.
- agent.ts collapsed to a single structured path. Removed:
-
What this buys us:
- Single code path for every env type. No more "does this task use PTY or structured?" branches scattered across agent.ts, environment.ts, tasks routes, desktop components.
- One storage format going forward (
tasks.transcript).tasks.terminal_outputis kept read-only for historical rows. - Lighter Electron bundle — one fewer native dep (node-pty) + ~3 xterm.js packages gone. Notable on Windows where node-pty was a recurring build-nightmare.
- Any new agent feature touches one surface (structured) — no parity-between-paths work.
-
Deferred follow-ups: backend-restart reliability (task #6 — designing keep-alive-across-restarts; not in Slice 4's scope). Optionally dropping
tasks.terminal_output+agents.terminal_output+ theenvironments.renderercolumn once we're confident no rollback is needed. -
Files:
packages/shared/src/daemonProtocol.ts,packages/backend/src/services/agent.ts,agentStructured.ts,environment.ts,ssh.ts,git.ts,packages/backend/src/routes/tasks.ts,routes/environments.ts,packages/backend/src/__tests__/helpers/fakeEnvironment.ts,packages/backend/src/__tests__/agent.envPrefix.test.ts(deleted),agent.statusDetection.test.ts(deleted),packages/backend/src/db/migrations/0008_default_structured_renderer.sql(new),packages/backend/src/db/migrations/meta/0008_snapshot.json(new),packages/backend/src/db/schema.ts,packages/backend/package.json,packages/daemon/src/executor.ts,wsClient.ts,packages/daemon/package.json,apps/desktop/package.json,apps/desktop/src/renderer/components/panels/TaskTerminal.tsx,TerminalHistory.tsx,apps/desktop/src/renderer/components/terminal/XTerm.tsx(deleted),apps/desktop/.erb/mocks/xtermMock.js(deleted),Dockerfile,scripts/install-daemon.sh,package-lock.json.
Interactive structured tasks: user-initiated tasks on a structured local env now run against a long-lived claude -p --input-format stream-json --output-format stream-json child. User types, child processes a turn, emits a result event, we flip status to idle, user can type again. Same strict-permission machinery as Slice 2 still applies — hook fires on every tool, UI shows Approve/Deny inline. Plus a batch of reliability / UX polish:
-
Streaming-input mode in
agentStructured.ts: newinteractive: booleanoption onStructuredRunOptions. When true, args include--input-format stream-json, the seed prompt is wrapped as a stream-json{type:"user", message:...}envelope, and stdin stays open. New methods:sendMessage(sessionKey, text)— writes a user-message JSONL envelope to the child's stdin. Throws if the run is one-shot or stdin is already closed.closeInput(sessionKey)— graceful end of conversation. Child finalises current turn, exits with code 0, task →awaiting_review.stop(sessionKey)unchanged — hard SIGTERM for aborts.
-
Turn-complete signalling: new
turn_completeevent emitted on eachresult.agentServicelistens and flips agent status back toidlefor interactive runs so the desktop re-enables the input box. -
Dispatcher change (
agent.ts): dropped theautonomous && promptgate. A structured local env now drives all tasks through the structured path — autonomous ones as one-shot, user-initiated ones as interactive.sendInput(agentId, text)routes toagentStructuredService.sendMessage()for structured sessions, existingwriteToSession()for PTY. -
No more timeout on permission prompts: removed
DECISION_TIMEOUT_MS+ setTimeout frompermissionService. Pending requests now wait indefinitely. Rationale: matches the "inbox item sits until you look at it" mental model; backend-restart case was already handled by SIGPIPE-on-closed-pipe killing the child regardless; cheapens a queued prompt to a setImmediate-level wait instead of a live-timer. -
Inbox coalescer for pending prompts (
packages/backend/src/services/permissionInbox.ts, ~140 LOC): subscribes topermissionServiceevents at backend init. First pending prompt on a task inserts oneagent_questioninbox item. Subsequent prompts bump a counter + swap the summary in place (no new items). Last pending resolved →status: 'actioned'+actionedAtstamp. Had to add aninsertReadypromise to the tracked entry so concurrent update requests await the initial INSERT — otherwise UPDATEs could silently hit 0 rows against a not-yet-persistedid. -
Boot-time orphan cleanup (
agent.tscleanupStaleAgents): extended to also flip the orphaned tasks themselves fromin_progress→failedwithresult.error = 'backend restart orphaned the agent'. PreviouslycleanupStaleAgentsonly dropped agent rows; tasks would ghost for up to 20 min untilrecoverStuckTaskscaught them. Now the post-deploy ghost window is seconds. (Deeper reliability work — keeping children alive across restarts — is queued as a follow-up; not in scope for Slice 3.) -
Desktop input bar upgrade (
TaskTerminal.tsx): old single-line<input>replaced with an auto-growing<textarea>(1–8 rows). Enter sends; Shift+Enter inserts a newline. Structured tasks: send disabled while the agent isworking/tool_use; placeholder reflects state ("Claude is working…", "Type your response…", etc.). PTY tasks: behaviour unchanged (always enabled — answering TUI prompts needs immediate writes). -
Deferred to follow-ups (not in Slice 3 despite being on the original plan):
- Session resume across process restarts (needs
--session-id+ dropping--no-session-persistence). ThehandleStructuredExitpath currently writesfailedon non-zero exit — resuming would require a different lifecycle. - Slash-command palette UI (Cmd+K). Not needed: the child's own parser handles
/clear,/model,/compact, etc. when we pass the text through as a user message. @filerefs, image paste,!shell. Parity polish; independent from this plumbing.
- Session resume across process restarts (needs
-
Tests (+5 inbox, +3 agentStructured):
permissionInbox.test.tscovers first-request creates item, coalescing with counter, last-resolved auto-actions, per-task separation.agentStructured.test.tsextended withbuildClaudeArgsassertions for interactive flag + strict-mode + interactive combined. Full suite: 109 tests passing in ~41s. -
Files:
packages/backend/src/services/agentStructured.ts,packages/backend/src/services/agent.ts,packages/backend/src/services/permissionService.ts,packages/backend/src/services/permissionInbox.ts(new),packages/backend/src/index.ts,packages/backend/src/__tests__/permissionInbox.test.ts(new),packages/backend/src/__tests__/permissionService.test.ts,packages/backend/src/__tests__/agentStructured.test.ts,apps/desktop/src/renderer/components/panels/TaskTerminal.tsx.
Builds on Slice 1's plumbing. Strict-mode autonomous tasks now run through a PreToolUse hook that blocks the CLI on every tool call until the user clicks Approve / Deny in the desktop. "Allow always" persists onto an env-scoped tool allowlist so repeated approvals stop pestering you. The conversation UI replaces Slice 1's interim event dump with a proper block view.
-
Hook mechanism (
packages/backend/src/services/permissionHook.ts): a dependency-free CJS script written to/tmp/fastowl-hook-<random>/permission.cjsat first strict-mode run. Reads the PreToolUse JSON on stdin, POSTs to the backend withx-fastowl-permission-token, writes the{hookSpecificOutput:{permissionDecision}}decision to stdout. Defaults todenyon any error — a broken backend never silently grants a tool. Script lives for the backend process lifetime; idempotent writer. -
Permission service (
packages/backend/src/services/permissionService.ts, ~200 LOC): in-process state machine.registerRun()mints a per-run token (random 24 bytes, hex) the child needs to present for any permission call;verifyRunTokenis timing-safe.requestDecision()short-circuits toallowif the tool is onenvironments.tool_allowlist, else registers a pending entry and emits arequestevent, awaitingrespond(). 10-minute auto-deny timeout.unregisterRun()on agent exit denies any still-pending requests so a killed child never leaves the CLI wedged. -
Routes (
packages/backend/src/routes/permission.ts):POST /api/v1/permission-hook(unauth'd by JWT, token-auth'd via header) is what the child hook hits.POST /api/v1/tasks/:id/permission(JWT-auth'd) is what the desktop hits when the user clicks a button — ownership checked viatasks → environments.owner_id.GET /api/v1/tasks/:id/permission/pendingreplays open prompts for reconnect. -
Schema:
0007_env_tool_allowlist.sqladdsenvironments.tool_allowlist jsonb default '[]'. Populated by the "Allow always" button. Scoped per-env (not per-task) — one approval sticks for every future task on that machine. -
Wire protocol: two new WS event types,
agent:permission_requestandagent:permission_response. We also inject syntheticfastowl_permission_request/fastowl_permission_response/fastowl_permission_auto_allowedevents into the transcript so the renderer has a single ordered stream; the dedicated WS types are kept for future standalone notification patterns. Force-persist on any fastowl-synthetic event so a reconnect mid-prompt sees the pending card (can't wait for the usual every-25-events sample). -
Dispatcher change (
packages/backend/src/services/agent.ts): structured runs now respectenv.autonomousBypassPermissions—true→--permission-mode bypassPermissions(no hook),false→--permission-mode defaultwith the hook. Bypass for throwaway daemons, strict for everything you care about. Strict mode also setsTALYN_PERMISSION_TOKEN+TALYN_AGENT_ID+TALYN_ENVIRONMENT_IDin the child's env so the hook can authenticate and the backend can scope allowlist lookups. -
Renderer (
apps/desktop/src/renderer/components/terminal/AgentConversation.tsx, ~450 LOC): replaces the interimStructuredTranscript.tsx(deleted). Collapses the event stream into a block model (text / thinking / tool_use / tool_result / permission / system / result) and renders each block with its own component. Text blocks get a hand-rolled markdown-ish renderer (newlines preserved, fenced code blocks, inline backticks — no new deps). Tool_use / tool_result / thinking blocks are collapsed by default; click to expand to full JSON / raw output. Permission blocks show the tool name + JSON input + three buttons: Allow once, Allow always (tool), Deny; auto-collapse into a green/red summary when the correspondingfastowl_permission_responseevent arrives. Footer shows cost / tokens / denial count from theresultevent. -
Desktop API (
apps/desktop/src/renderer/lib/api.ts): newapi.tasks.respondToPermission(taskId, requestId, decision, persist)+api.tasks.listPendingPermissions(taskId). -
Tests (+13):
permissionService.test.tscovers the full state machine — token mint + verify, pre-approved tool auto-allows without emitting a request event, non-approved tool registers pending + firesrequest, allow+persist writes the allowlist, allow-without-persist doesn't, unknown requestId returns false, 10-minute timeout auto-denies,unregisterRunresolves pending as denied,listPendingForTaskscoping. Usesvi.useFakeTimers()for the timeout assertion. Full suite: 115 tests passing in ~30s. -
Deliberate scope boundaries for Slice 2:
- Still autonomous-only on local envs (same gate as Slice 1). Interactive user-initiated tasks land in Slice 3.
- Allowlist is exact tool-name match (
Read,Bash). Pattern matching likeBash(git *)— which the CLI's own--allowedToolssupports — comes later if users want it. - Daemon / SSH envs still use PTY (they don't have the hook script or a streaming-exec op yet). Structured + these env types is a Slice 4 follow-up.
- No global "allow any of: Read, Grep, Glob" preset — the user has to approve each distinct tool once, then "Allow always" sticks it.
-
Files:
packages/shared/src/index.ts(permission types + new WS event types),packages/backend/src/db/schema.ts,packages/backend/src/db/migrations/0007_env_tool_allowlist.sql(new),packages/backend/src/db/migrations/meta/0007_snapshot.json(new),packages/backend/src/services/permissionService.ts(new),packages/backend/src/services/permissionHook.ts(new),packages/backend/src/services/agentStructured.ts,packages/backend/src/services/agent.ts,packages/backend/src/services/environment.ts,packages/backend/src/services/websocket.ts,packages/backend/src/routes/permission.ts(new),packages/backend/src/routes/environments.ts,packages/backend/src/routes/index.ts,packages/backend/src/__tests__/permissionService.test.ts(new),apps/desktop/src/renderer/components/terminal/AgentConversation.tsx(new),apps/desktop/src/renderer/components/terminal/StructuredTranscript.tsx(deleted),apps/desktop/src/renderer/components/panels/TaskTerminal.tsx,apps/desktop/src/renderer/components/panels/TerminalHistory.tsx,apps/desktop/src/renderer/lib/api.ts.
Start of the move from raw-PTY CLI output to a structured conversation renderer. The original plan was to swap the claude CLI for @anthropic-ai/claude-agent-sdk, but research + a spike showed the SDK is API-key-only by policy — Claude Pro/Max subscription auth is explicitly unsupported, so migrating would force every existing user onto metered API billing. Path C instead: keep spawning the claude binary (so OAuth subscription auth continues to work) but switch to --output-format stream-json --verbose --include-partial-messages, which emits the same structured events the SDK does. All three planned phases (A autonomous-strict, B autonomous-bypass, C interactive) land on this shared foundation.
-
Spike findings (documented before coding):
claude -p --output-format stream-json --verboseemits JSONL forsystem/assistant/user/stream_event/result— content blocks includetext,thinking,tool_use,tool_result. Init event showsapiKeySource: "none"confirming OAuth creds from~/.claude/are honored.--include-partial-messagesaddscontent_block_deltaevents (chunky but usable text streaming).PreToolUsehooks configured via--settings '<inline-json>'synchronously gate tool use — our eventual permission-callback path for Slice 2.- Inline
--settingsJSON works, so no temp-file-per-spawn plumbing needed.
-
Shared types (
packages/shared/src/index.ts):Environment.renderer: 'pty' | 'structured'+ newEnvironmentRenderer.Task.transcript?: AgentEvent[].AgentEventdefined permissively (mirrors the CLI's own schema —type, optionalsubtype,message,event,result, etc., plus our ownseq: numberfor ordering). Two new WS event types:agent:eventandtask:eventwithAgentEventBroadcast/TaskEventBroadcastpayloads. -
DB migration (
0006_structured_renderer.sql):environments.renderer(text, default'pty'),tasks.transcript(jsonb nullable). Fresh installs default to'pty'— no behavioural change for existing envs/tasks. -
New service (
packages/backend/src/services/agentStructured.ts, ~230 LOC):AgentStructuredService.start(opts)non-PTY-spawnsclaudewith the stream-json argv, writes the prompt on stdin, and parses stdout line by line viaJsonlLineParser.- Each parsed event gets a monotonic
seqstamp, appended to an in-memory transcript, broadcast asagent:event+task:event, and persisted totasks.transcriptevery 25 events (and unconditionally ontype === 'result'). - Transcripts are capped at
TRANSCRIPT_MAX_EVENTS = 2000: above the cap, the middle drops out with a{type: 'system', subtype: 'truncated'}marker. Prevents one unruly autonomous task from nuking the jsonb column. stop()kills the child with SIGTERM; thecompletionpromise resolves with whatever exit code the child produces.- Stderr from the child is surfaced as synthetic
system/stderrevents so the UI can render CLI misbehaviour.
-
Dispatcher (
packages/backend/src/services/agent.ts):startAgentchecksenv.renderer === 'structured' && env.type === 'local' && autonomous && prompt— if true, calls the newstartStructuredAgentpath; otherwise the existing PTY path. The structured path inserts the sameagents/tasksrows (so inbox, task list, stop endpoint all keep working uniformly), writestask.metadata.runtime = 'structured'so the UI can pick the right renderer, and maps exit code onto the existingawaiting_review/failedrules via a newhandleStructuredExit.stopAgentroutes toagentStructuredService.stop()for structured sessions and the existing PTY kill for everyone else. -
Routes (
packages/backend/src/routes/environments.ts):POST /environmentsaccepts optionalrendereron create (defaults to'pty', silently falls back to'pty'for non-local envs in Slice 1).PATCH /environments/:idhonorsrendererupdates with the same guard. Both echorendererin responses.GET /tasks/:id/terminalnow returns{ terminalOutput, transcript, runtime }so callers can pick the right renderer. -
WS helpers (
packages/backend/src/services/websocket.ts):emitAgentEvent+emitTaskEventbroadcast structured events to workspace subscribers. -
Desktop:
- New
apps/desktop/src/renderer/components/terminal/StructuredTranscript.tsx(interim Slice-1 renderer): one line per event, colour-coded by type, with a one-line summary (text snippet,→ tool(args),← ok/err, cost forresult). Replaced by Slice 2'sAgentConversation.tsx. TaskTerminal.tsxbranches ontask.metadata.runtime === 'structured'— rendersStructuredTranscriptinstead ofXTerm.TerminalHistory.tsxrewritten to fetch{ terminalOutput, transcript, runtime }and pick the renderer per-task.useApi.tssubscribes totask:event, dedups byseq, maintains a sorted transcript on the task store entry.
- New
-
Tests: 12 new unit tests in
agentStructured.test.tscovering the JSONL parser (partial-line buffering, multi-chunk assembly, blank-line handling, malformed-line tolerance) +buildClaudeArgs(bypass mode flag, stream-json defaults, session-persistence disabled). Full suite: 101 tests passing in ~27s. The end-to-end spawn path is easiest to validate by hand with a running backend — no fake-CLI fixture yet. -
Deliberate scope boundaries for Slice 1:
- Only wired for
autonomous && prompttasks onlocalenvs. Interactive user-initiated tasks + SSH/daemon envs stay on the existing PTY path until Slice 2/3 and a daemon-side follow-up. - Bypass-permissions only. Per-tool Approve/Deny UI comes in Slice 2 via a
PreToolUsehook invoking an in-process FastOwl endpoint. - Interim renderer is deliberately ugly — validates plumbing; Slice 2 builds the markdown + collapsible-tool-call conversation UI.
- No back-migration of historical
terminal_output— legacy PTY tasks keep rendering via XTerm forever; the runtime field is sticky per task.
- Only wired for
-
Files:
packages/shared/src/index.ts,packages/backend/src/db/schema.ts,packages/backend/src/db/migrations/0006_structured_renderer.sql(new),packages/backend/src/db/migrations/meta/0006_snapshot.json(new, regenerated journal),packages/backend/src/services/agentStructured.ts(new),packages/backend/src/services/agent.ts,packages/backend/src/services/environment.ts,packages/backend/src/services/websocket.ts,packages/backend/src/routes/environments.ts,packages/backend/src/routes/tasks.ts,packages/backend/src/__tests__/agentStructured.test.ts(new),apps/desktop/src/renderer/components/terminal/StructuredTranscript.tsx(new),apps/desktop/src/renderer/components/panels/TaskTerminal.tsx,apps/desktop/src/renderer/components/panels/TerminalHistory.tsx,apps/desktop/src/renderer/hooks/useApi.ts,apps/desktop/src/renderer/lib/api.ts.
Pass over the Continuous Build scheduler + task queue to close the "runs unattended overnight" part of the DoD. Three cascades fixed: deterministic-failure infinite loop, ghost tasks that never recover from a silent agent death, and the markdown-sync-clobbers-running-task case.
-
Failure counter + backoff + auto-block (
services/continuousBuild.ts+services/backlog/service.ts):- New columns on
backlog_items:consecutive_failures(int, default 0) +last_failure_at(timestamptz, nullable). Migration0004_backlog_failure_tracking.sql. - Scheduler's
onTaskStatusnow distinguishesfailed(counts as a failure, bumps counter + stamps time) fromcancelled(user-initiated, doesn't count). Completed/approved resets the counter to 0. - Backoff schedule: 1m → 5m → 15m → 60m by failure count.
nextActionableItemfilters onlastFailureAt <= cutoffand the scheduler re-checks the backoff window for the candidate. A looping broken TODO can't hog the queue anymore. - After 5 consecutive failures the item flips to
blocked. Human has to fix whatever's deterministically wrong, then unblock it in the UI.
- New columns on
-
Periodic stuck-task recovery (
services/taskQueue.ts):recoverStuckTasksused to run only atinit(). Now also runs every 2 minutes on a timer, and the query picks up an extra case — tasks whoseupdated_athasn't moved in 20 minutes (proxy for "agent silently dropped"). Covers daemon disconnects mid-task, hung processes, etc. — previously those required a service restart to clear. -
Guard claimed items against sync auto-completion (
services/backlog/service.ts): when a backlog item disappears from the markdown source,syncSourceauto-marks it completed — but only if it's not currently claimed. Previously a running task could have its item silently marked complete by a concurrent markdown edit, orphaning the task's work. -
Tests (+7 total): 5 scheduler tests (failure → counter bump, backoff window, 5th failure blocks, cancelled doesn't count, complete clears counter, sync-with-claim is no-op), 1 taskQueue test (time-based staleness recovery), 1 backlog test (claim survives sync-side-delete). Full suite stays fast — 74+6 = 80 tests in ~11s.
-
Why these three and not others from the failure-path audit: the audit (via explore subagent) turned up more — orphaned git branches, fire-and-forget promise paths in agent status updates, approval-reject flow — but these three were the direct blockers for "unattended overnight": an infinite loop is catastrophic, a stuck task needs periodic rescue, and a sync-race is a silent data-loss bug. The others are quality-of-life and can land when they land.
-
Schema note:
BacklogItemgains two fields in@talyn/shared—consecutiveFailures: number+lastFailureAt?: string. Renderer components that destructure backlog items keep working (new fields are additive); the UI doesn't render them yet, but they're available for a future "this item has failed N times" badge. -
Files:
packages/backend/src/services/continuousBuild.ts,packages/backend/src/services/backlog/service.ts,packages/backend/src/services/taskQueue.ts,packages/backend/src/db/schema.ts,packages/backend/src/db/migrations/0004_backlog_failure_tracking.sql(new),packages/shared/src/index.ts, tests across three files.
CI (and local npm test) had been timing out in daemonRegistry.test.ts. Diagnosed as a race between markEnvConnected (fired by register()) and markEnvDisconnected (fired by unregister()) — both are fire-and-forget .update() calls on the same environment row. Under pglite (the test harness), running two unawaited UPDATEs on the same row concurrently pins the worker at 100% CPU inside pglite's WASM scheduler. Bisected down from the whole file → to the fourth test ("disconnecting a daemon rejects its in-flight requests") — the one case that exercises both register+unregister inline — and traced it to a hang at pglite.waitReady in the next test's beforeEach (WASM init starves once the previous test leaves pending in-flight queries behind).
- Fix: introduced a private
dbTail: Promise<void>indaemonRegistrythat serializes every env-status flip.markEnvConnectedandmarkEnvDisconnectednow.then()-append ontodbTailso writes happen in order, never concurrently for the same row. AddedflushPending()and madeshutdown()async+ awaitflushPending()so tests cleanly drain before pglite closes. - Callers updated:
packages/backend/src/index.tsSIGTERM handler +daemonRegistry.test.tsafterEach nowawait daemonRegistry.shutdown(). - Result: full backend suite goes from timing out to 74/74 passed in 11.6s.
daemonRegistry.test.tson its own: 5/5 in 4s. - Why the race didn't show up on real Postgres: a real connection supports multiple concurrent statements; pglite serializes through a single WASM instance and the fire-and-forget pattern leaves the worker's microtask queue clogged when the following test tries to spin up a fresh pglite. Production (Supabase) was fine.
- Files:
packages/backend/src/services/daemonRegistry.ts,packages/backend/src/index.ts,packages/backend/src/__tests__/daemonRegistry.test.ts.
The "give me SSH creds and I'll do the rest" path. Desktop's Add Environment dialog now has a Remote VM (FastOwl daemon) type with two modes: auto-install over SSH (backend SSHes in and runs a hosted install script) or manual (shows a copy-paste one-liner). Either way, a daemon env is created, a pairing token is minted, and the env flips to connected as soon as the daemon dials back — no user JWT ever touches the VM.
-
Shared types: added
DaemonEnvironmentConfig(type: 'daemon',hostname?,workingDirectory?) to theEnvironmentConfigunion +InstallDaemonOverSshRequest/Response. Keeps the Environment type honest now that daemon envs are first-class. -
scripts/install-daemon.sh(new): OS-aware provisioning script served via the backend. Installs Node 22 (NodeSource on Debian/Ubuntu, yum-nodesource on RHEL,brewon macOS, nvm fallback), installsbuild-essential+python3on Linux for node-pty, clonesGilbert09/owl, builds@talyn/shared+@talyn/daemon, runs the daemon once in foreground with--pairing-tokento exchange for a device token (watches the on-disk config file fordeviceTokento appear, times out at 60s), then writes a systemd unit at/etc/systemd/system/fastowl-daemon.service(Linux) or a launchd plist at~/Library/LaunchAgents/dev.fastowl.daemon.plist(darwin). Idempotent — safe to re-run. -
Backend public route (
routes/daemon.ts):GET /daemon/install.shserves the script. Unauthenticated by design — the credential is the pairing token, not the HTTP request. Dockerfile nowCOPY scripts ./scriptsso the script is on disk at runtime. -
Backend SSH installer (
services/daemonInstaller.ts): uses ssh2 to dial the target, supportspassword+privateKeyauth (raw PEM content, not file paths — the private key gets pasted into the desktop UI and is used once per install), exec'scurl -fsSL <backend>/daemon/install.sh | bash -s -- --backend-url ... --pairing-token ..., captures stdout+stderr, returns the log. 5-minute timeout. -
Backend route:
POST /api/v1/environments/:id/install-daemon— owner-scoped, validates env type isdaemon, mints a fresh pairing token on every call, resolves the backend URL fromTALYN_PUBLIC_BACKEND_URLenv var (falls back toreq.protocol://req.host), hands off toinstallDaemonOverSsh. Returns{ success, log, exitCode, backendUrl }. -
Desktop UI (
AddEnvironmentModal.tsx): rewritten around three types. "Remote VM (FastOwl daemon)" is the new default for cloud-backend users; "SSH (legacy)" is kept behind a warning for local-backend users. In daemon/ssh-install mode: host/port/user + (password | pasted PEM key + optional passphrase). In daemon/manual mode: after creation, shows the copy-paste one-liner with a Copy button. Either way, after submit, the modal pollsGET /environments/:idevery 3s and flips to "Daemon connected!" when the backend sees the daemon dial back. -
Docs: Roadmap 18.3 flipped to
[x]for remote install; single-file binary is deferred (git-clone install works end-to-end). Priority queue now has 17.3 (notifications) at the top. -
Design decisions:
- Git clone, not a prebuilt binary — the MVP install path shells out to
git clone+npm install+npm run buildrather than shipping a prebuilt tarball. Reasons:node-ptyis a native module, and cross-compiling a binary that works on linux/amd64 + linux/arm64 + darwin/arm64 adds a whole CI pipeline. The git-clone path uses whatever Node is on the target, builds native modules in place, and avoids a new release surface. Downside: first install on a VM takes ~2 minutes instead of ~10 seconds. Acceptable for now. - Pasted PEM instead of key file — the hosted backend can't read the user's
~/.ssh/id_rsa. The install endpoint accepts the private key contents in the request body, uses it for a single ssh2 connection, and never stores it. Memory-only, dies with the request. Same principle as the install-script one-liner: the credential exists in the path of the install and nowhere else. - One pairing token per install call — every
POST /install-daemoninvocation mints a fresh token (even for the same env). Avoids the "pairing token reuse" failure mode if the previous install timed out or was interrupted. Tokens expire in 10min anyway, so there's no cleanup debt. - Polling instead of WebSocket for "daemon connected" — the modal polls the env's status every 3s. Could push an
environment:statusWS event (we already emit them), but the modal is short-lived enough that polling is simpler than hooking into the store and filtering.
- Git clone, not a prebuilt binary — the MVP install path shells out to
-
Still to land (deferred):
- Symmetric uninstall flow (delete env → SSH in → systemctl disable + rm). Not critical.
- Prebuilt daemon binary (
bun --compile) — avoids the ~2min first-install npm install step. Nice-to-have. - Wire-up streaming install logs to the modal via WS (today we only show the log after the install finishes). UX nit.
- End-to-end test of the install flow against a real VM. Covered manually; no CI yet.
-
Files touched:
packages/shared/src/index.ts(DaemonEnvironmentConfig + install API types);scripts/install-daemon.sh(new);packages/backend/src/routes/daemon.ts(new);packages/backend/src/routes/index.ts(mount/daemon);packages/backend/src/services/daemonInstaller.ts(new);packages/backend/src/routes/environments.ts(install-daemon endpoint);Dockerfile(COPY scripts);apps/desktop/src/renderer/lib/api.ts(pairingToken + installDaemon helpers);apps/desktop/src/renderer/components/modals/AddEnvironmentModal.tsx(rewritten). -
How to exercise it locally:
npm run dev -w @talyn/backend(local backend on 4747)- Open desktop, Settings → Environments → Add
- Pick Remote VM (FastOwl daemon) → Show me the install command (the SSH path requires a real VM)
- Name it, Generate → copy the one-liner
- On any VM: paste the command (it'll curl from
http://localhost:4747/daemon/install.shwhich only works from the same network; for a real test, setTALYN_PUBLIC_BACKEND_URLto the hosted URL) - Modal flips to "Daemon connected!" when the daemon dials back.
-
Next action: Phase 18.2 polish (proper
fastowl loginPKCE + CLI refresh-token rotation + cross-user HTTP-layer integration test) or Phase 18.3 polish (single-file daemon binary viabun --compile).
Desktop OS notification fires when any task transitions into awaiting_review. Implementation is surprisingly small — the renderer already subscribes to task:status events; added a pre-update status check to detect the transition (to avoid firing on idempotent restates), then new Notification(...) in the granted-permission path. Electron bridges the renderer-side Notification constructor to the native OS surface — no preload work, no main-process IPC.
- Preference: stored in
localStorageunderfastowl:notify:awaitingReview. Default on. Toggled from Settings → Appearance → Notifications. - Permission: requested lazily on first-eligible event. Settings toggle also requests eagerly on flip-to-on so the permission prompt doesn't race with the actual event. When the OS-level permission is denied, the settings panel surfaces a "Notifications are blocked at the OS level" hint.
- Click-through:
n.onclick = () => window.focus()brings the app forward. Could later deep-link to the specific task (route + select) but the inbox + queue are both visible on the main screen. - Transition semantics: we grab the previous task from the store BEFORE applying the update, so
wasAwaitingReviewreflects the prior state. If a WS event arrives that re-statesawaiting_reviewwithout a transition (recovery path, duplicate event), no notification fires. - Files:
apps/desktop/src/renderer/hooks/useApi.ts(newmaybeNotifyAwaitingReview+ pref helpers);apps/desktop/src/renderer/components/panels/SettingsPanel.tsx(Notifications card in AppearanceSettings);docs/ROADMAP.md+CLAUDE.md+ this note. - Deferred: per-task-type toggles, digest mode, click-through that deep-links to the task. None block the "production ready" goal.
Option-1 relay shipped. Child processes spawned by a daemon (claude running a task, fastowl CLI calls from within that Claude, any MCP server) now reach the backend through a local HTTP proxy on the daemon, which tunnels each request over the daemon's authenticated WS. No user JWT ever lives on the VM.
-
Protocol: added
ProxyHttpRequest/ProxyHttpResultto the daemon↔backend wire. Request is { method, path, headers, body (base64) } — full REST round-trip, not a typed RPC surface. Keeps every existing route available to daemon children without duplicating the API. -
Backend auth refactor:
requireAuthnow accepts two credential paths. Path 1 (existing):Authorization: Bearer <Supabase JWT>. Path 2 (new):X-Fastowl-Internal-User: <uuid>+X-Fastowl-Internal-Token: <secret>. The secret is minted once at process boot withrandomBytes(48)and held only in memory — reboot rotates it. Comparison istimingSafeEqual. Internal requests resolve the user from theuserstable directly, skipping the Supabase round-trip. -
Backend proxy dispatcher (
services/daemonProxyHandler.ts): when a daemon sendsproxy_http_requeston its WS, backend looks upenv.owner_id, makes a localhostfetchagainsthttp://127.0.0.1:${PORT}${path}withinternalProxyHeaders(ownerId), and ships the response back in aproxy_http_response. Dropsauthorization,cookie,host, and hop-by-hop headers from the inbound side; dropscontent-length/transfer-encodingfrom the outbound response (daemon recomputes). -
Daemon proxy server (
proxyServer.ts): HTTP server bound to127.0.0.1:0(random port). Every inbound request is serialized intoproxy_http_request, sent over the WS, and awaited up to 60s. On daemon start,TALYN_API_URL=http://127.0.0.1:<port>is set as a child-env override;TALYN_AUTH_TOKENis always scrubbed from the spawn env so a stale user token can't leak through. -
Daemon WS client: now sends daemon→backend
requestmessages (previously only events). Tracks its ownpendingProxyRequestsmap with 60s timeouts; rejects them all on shutdown. -
Tests:
daemonProxy.test.tsmountsrequireAuthon a minimal Express app and exercises the internal-header path — valid user, wrong token, unknown user. All four pass; full backend suite is 74/74. -
Still to land in 18.3.B:
- Rewire scheduler / taskQueue so tasks actually execute on
daemonenvs end-to-end (today they still prefer legacylocal/ssh). fastowl-daemon install+ server-hostedinstall.sh+ tarball publication (probably from Railway/daemon/latest.tar.gzfor MVP).- Desktop "Add SSH environment → Install FastOwl daemon" checkbox that SSHes in, runs the install, polls for the daemon to dial back.
- Ownership propagation: provisioning an env + dispatching a proxy request both hinge on
env.owner_id; need a regression test that covers user-A-VM cannot proxy as user-B.
- Rewire scheduler / taskQueue so tasks actually execute on
-
How to exercise the relay today:
npm run dev -w @talyn/backend- Create a daemon env + pairing token via REST (auth'd with your CLI token as before).
node packages/daemon/dist/index.js --pairing-token <x> --backend-url http://localhost:4747- Daemon logs
listening on http://127.0.0.1:<port>. - From the shell where the daemon is running:
TALYN_API_URL=http://127.0.0.1:<port> TALYN_AUTH_TOKEN= fastowl workspace list— request hits the local proxy, tunnels over WS, backend answers as the daemon's owner.
-
Follow-up commits landed same session:
a0000eaDaemon envs are first-class in scheduling: daemonRegistry updatesenvironments.statuson register/unregister;backlogServiceandcontinuousBuildSchedulerfall back to any connected daemon when no env is pinned;connectSavedEnvironmentson startup marks daemon envs disconnected until they dial back.9e82bc7CI hygiene:@talyn/daemongets--passWithNoTestsso an empty suite doesn't fail CI;taskQueueServicegains ashuttingDownflag +runProcessQueuewrapper that swallows the "DATABASE_URL is not set" noise triggered by floating promises after a test's DB reset; AuthProvider no longerconsole.errors when Supabase env vars are missing (LoginScreen already surfaces a visible warning).
Foundation for the SSH auto-install flow. Daemon package exists and can dial the hosted backend; backend has a /daemon-ws endpoint, a registry that tracks live daemons, and a daemon env type that proxies commands through. No UX change yet — Phase 18.3.B bolts the "Install daemon" checkbox onto the Add-SSH-env dialog.
-
Wire protocol in
@talyn/shared/daemonProtocol.ts: JSON-framed WS envelopes withhello/hello_ack/request/response/event. Correlation IDs on request/response. Close codes in the 4xxx range for a daemon to log a clear reason (4401 unauthorized, 4409 duplicate, 4500 server shutdown). Encoded asJSON.stringify(envelope)so the same types also work over stdio if we ever need a local test daemon. -
packages/daemon(new workspace):executor.tswrapschild_process.spawn+node-pty,git.tsmirrors backendgitServicevia exec,wsClient.tshandles the dial/hello/reconnect loop (exponential backoff capped at 30 s),config.tsresolves CLI args / env vars /~/.fastowl/daemon.jsonwith that precedence. Bin isfastowl-daemon. -
Schema:
environmentsgetsdevice_token_hash(SHA-256 of the long-lived daemon token) andlast_seen_at, plus a new env typedaemon. Migration 0003.0002_snapshot.jsongot re-ided because Stage 5's manual copy had a duplicate id that collided with drizzle-kit on regen. -
Backend:
services/daemonRegistry.tsowns pairings (in-memory, 10 min TTL) and live daemon connections. Mints device tokens, matches them on reconnect, issues requests with 30 s timeouts, routes responses by correlation id, forwards events assession.data/session.close/statusEventEmitter events. No background timers — pairing expiry is swept inline on eachauthenticatecall so tests don't have to deal with open timer handles.services/daemonWs.tsaccepts connections at/daemon-ws, enforces a 5-second hello timeout, hands auth off to the registry, then routes subsequent messages.services/environment.tsgainedcase 'daemon':branches forconnect,exec,spawnInteractive,writeToSession,killSession,getStatus. Sub-daemon events flow back through the existingsession:data/session:closeEventEmitter the rest of the backend already listens for.index.ts: separateWebSocketServer({ noServer: true })for daemon upgrades, path-dispatched on the HTTPupgradeevent so the existing/wskeeps its own handler.routes/environments.ts: newPOST /:id/pairing-tokenmints a one-shot pairing token for a daemon env. Validates ownership + env type. 10-minute TTL.
-
Tests:
daemonRegistry.test.tscovers pairing-then-device handshake, reconnect-with-device-token, request/response round-trip, in-flight rejection on disconnect, and event forwarding. Uses aFakeWsEventEmitter stand-in so no sockets or network. 70/70 green. -
Deliberately deferred to follow-ups:
- Bundled daemon spawn from Electron main — the user has to run the daemon manually (CLI) for now. Next: desktop spawns daemon as a child process on app start, creates a local daemon env, pairs automatically.
- Liveness heartbeat (periodic
last_seen_atstamp while connected) — today it's set on register only. - UI to create a daemon env + show the
fastowl-daemon --pairing-token X --backend-url Ycommand. - Legacy
local/sshenv types still exist and still work when the backend runs on the user's laptop; only thedaemontype works against the hosted backend.
-
How to try it locally (dev loop):
- Point desktop at local backend:
TALYN_API_URL=http://localhost:4747inapps/desktop/.env, rebuild. - Start the backend (
npm run dev -w @talyn/backend). - Create a daemon env via API:
POST /api/v1/environmentswith{ "type": "daemon", "name": "My Mac", "config": {} }(requires bearer token from desktop login → Copy CLI token). - Mint a pairing token:
POST /api/v1/environments/:id/pairing-token. - Run the daemon:
node packages/daemon/dist/index.js --pairing-token <token> --backend-url http://localhost:4747. - Watch it pair, write
~/.fastowl/daemon.json, stay connected. Restart with no args and it reconnects using the stored device token.
- Point desktop at local backend:
-
Next action (Phase 18.3.B): "Add SSH environment → Install FastOwl daemon" checkbox in the desktop dialog. Backend SSHes in, runs a server-hosted
install.sh, writes a systemd/launchd unit, starts the service. At that point: one click to onboard a VM.
Backend now live at https://fastowl-backend-production.up.railway.app. Health check passes, migrations ran on startup, RLS confirmed on every user-scoped table. Desktop .env flipped to point at Railway.
-
Dockerfile (multi-stage): builder installs the whole workspace + compiles with tsc + prunes to prod deps; runtime copies
node_modules+dist/+ migrations. Copying node_modules instead of reinstalling keepsnode-pty/ssh2native bindings intact without needing build tools in the runtime image..dockerignorekeeps the build context tight (no desktop release, no .env, no docs). -
Migrations fix:
tscdoesn't copy.sqlfiles, so the migrate-on-startup would have crashed in prod. Addedbuild:copy-migrationspostbuild script (fs.cpSync— ESM-safe, no shell) that mirrorssrc/db/migrations→dist/db/migrations. -
railway.toml: DOCKERFILE builder, healthcheck at
/health(30s window), restart on failure max 5 retries. -
CI:
.github/workflows/deploy-backend.ymldeploys on pushes to main that touch backend/shared/Dockerfile, usingRAILWAY_TOKENsecret. Path-filtered so desktop-only changes don't redeploy. -
Two gotchas that bit:
- Railway doesn't route IPv6; Supabase's direct
db.<ref>.supabase.coresolves IPv6. Fix: use the transaction pooler (aws-1-eu-west-2.pooler.supabase.com:6543). Session 12 had the wrong region prefix (aws-0-vsaws-1-— it's project-specific, copy from the dashboard). --ignore-scriptsonnpm ciin the runtime stage strips node-pty's native binary. Moved the install to the builder stage and copied the resultingnode_modulesacross — works without shipping python/build-essential to the runtime image.
- Railway doesn't route IPv6; Supabase's direct
-
Env vars on Railway (service
fastowl-backend):DATABASE_URL,SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,TALYN_ALLOWED_EMAILS=owerstom@googlemail.com,NODE_ENV=production.PORTauto-provided by Railway. -
Desktop:
apps/desktop/.envgainsTALYN_API_URL=https://fastowl-backend-production.up.railway.app. Commented fallback tohttp://localhost:4747for running against a local backend. -
Verified:
GET /healthreturns the full service payload;GET /api/v1/workspaceswithout auth returns 401 (middleware enforcing); Supabase query confirms RLS is on for all 10 user-scoped tables, off forsettings. -
Still outstanding:
- Add
RAILWAY_TOKENto GitHub repo secrets (manual; required before the deploy workflow actually runs). - Update workspace-integration GitHub OAuth app callback URL if/when we exercise it against the hosted backend.
- Extra Railway "FastOwl" service auto-created alongside
fastowl-backendcan be deleted via the dashboard — harmless but cluttered.
- Add
-
Next action: Phase 18.3 — daemon split + SSH auto-install. With the backend hosted, a VM now has a target to dial out to. Extract env/agent/git services into
packages/daemon, flip the connection direction, add the "Install FastOwl daemon" checkbox in the Add-SSH-env dialog.
Wired Supabase GitHub OAuth through backend, desktop, CLI, and MCP in five focused commits. Every REST endpoint and the WebSocket upgrade now require a valid Supabase JWT; data is scoped by owner_id at the app layer with RLS as defense in depth.
-
Schema + routes (
b267d0f): addeduserstable mirroringauth.users, addedowner_id(NOT NULL, FK) onworkspaces+environments— everything else inherits access through its workspace FK.requireAuthmiddleware verifies Supabase JWTs viaauth.getUser(token), upserts the user row on first sight, and enforcesTALYN_ALLOWED_EMAILSif set. Every route got ownership gates (helper:requireWorkspaceAccess,requireTaskAccess, etc.)./api/v1/github/callbackstays public — state-token lookup guards it. WebSocket accepts?token=on upgrade, verifies, then scopes subscribe requests to the connected user's workspaces. -
Desktop login (
3790764):AuthProviderwraps the app. Sign-in opens GitHub OAuth in the system browser viashell.openExternal, Supabase redirects tofastowl://auth-callback#access_token=..., the main process catches the deep link and forwards over IPC.api.tsattachesAuthorization: Bearerto every REST call and the WS upgrade query. Addedfastowl://to theprotocolsfield inpackage.jsonfor packaged builds. -
CLI + MCP (
4591c7a): CLI reads token from~/.fastowl/token(mode 0600) orTALYN_AUTH_TOKEN; newfastowl token set|show|clear|whoamicommands. MCP is env-only (parent agent setsTALYN_AUTH_TOKENon spawn). Desktop Settings gains an Account tab with sign-out and a one-click "Copy CLI token" button — tokens expire hourly so users re-copy as needed. Proper PKCEfastowl logindeferred. -
RLS (
4a9cdd6): migration enables RLS on all user-scoped tables + policies onauth.uid(). Test helper stubsauth.uid()so pglite can apply the migration; pglite's superuser connection bypasses RLS the same way the service role does in prod. -
Docs: this session note + SETUP.md (Supabase redirect URL, allow-list env var, desktop/CLI env conventions).
-
Key decisions (ratified with Tom):
- Ownership lives only on top-level tables (
workspaces,environments) +users. Child tables (tasks, agents, inbox, repos, integrations, backlog_sources, backlog_items) cascade access through the workspace FK. Simpler schema, simpler RLS, matches existing mental model. - Backend uses the service-role key + app-level owner filtering. Keeps Drizzle usage unchanged; no per-request Supabase client.
- Electron OAuth = system browser +
fastowl://deep link. Rejected embedded BrowserWindow (less secure, non-standard). - Allow-list env var for single-user mode; invite flow explicitly deferred (documented as TODO in ROADMAP 12.7).
- Ownership lives only on top-level tables (
-
Still on the list:
- Proper
fastowl loginwith PKCE code flow + local callback server (replaces copy-paste token UX). - Refresh-token rotation in CLI (right now CLI tokens expire in an hour, user re-copies).
- Cross-user integration test at the HTTP layer (today's coverage is: migration applies RLS, app-level helpers are structured around owner checks, but we don't spin up two users and assert user A's routes 404 on user B's resources).
- Invite flow +
workspaces_usersjoin table once FastOwl needs real multi-tenancy.
- Proper
-
Files touched: schema + 2 new migrations; new
middleware/auth.ts+services/supabase.ts; all 8 route files gated; newrenderer/components/auth/{AuthProvider,LoginScreen}.tsx+renderer/lib/supabase.ts;main/main.ts+preload.tsfor deep-link plumbing; CLIcommands/token.ts+config.ts; MCPclient.ts; Settings panel Account section. -
Next action: continue Phase 18.3 (daemon split + auto-install over SSH) or 17.3 (notifications). Auth is done enough to build on top of.
Started the hosted-backend work from docs/CONTINUOUS_BUILD_ROADMAP.md. Phases A + B complete end-to-end on hosted infra. Phase C started then paused to avoid a half-broken main; picks up next session from a known-green state.
-
Phase A (COMPLETED) — Drizzle ORM scaffolding:
packages/backend/src/db/schema.ts— Drizzle schema with all 10 tables (workspaces, repositories, integrations, environments, tasks, agents, inboxItems, settings, backlogSources, backlogItems). Upgraded types for Postgres:jsonbfor structured payloads (settings, config, metadata, result, actions, source, data),timestamp with time zonefor dates,booleanfor flags (no more 0/1 ints).packages/backend/src/db/client.ts— wraps postgres-js + drizzle-orm, exposesgetDbClient()singleton +setDbClient()/resetDbClient()test hooks. ExportsDatabasetype alias (the Drizzle query builder) that services will consume in Phase C.packages/backend/drizzle.config.ts— points schema →src/db/migrations/, dialect postgresql, casing snake_case.packages/backend/src/db/migrations/0000_initial.sql— generated bynpx drizzle-kit generate --name initial. 152 lines. This is the target state of the hosted DB; hand-rolled SQLite migrations 001-007 are being retired.- Scripts on backend
package.json:db:generate,db:migrate,db:studio. - Deps added:
drizzle-orm@^0.45.2,postgres@^3.4.9,drizzle-kit@^0.31.10(dev),@electric-sql/pglite@^0.4.4(dev, intended for Phase C tests). skipLibCheck: trueonpackages/backend/tsconfig.json(drizzle-orm/sqlite-core ships types that trip strict checks — harmless since we don't use that module).
-
Phase B (COMPLETED) — Supabase project provisioned via MCP:
- Organization:
nmgucldojryyubpdxdfg("FastOwl") - Project:
fastowl-prod— idxodyzfwlwvgzezwlkrqn, regioneu-west-2, statusACTIVE_HEALTHY, cost $0/mo - Project URL:
https://xodyzfwlwvgzezwlkrqn.supabase.co - All 10 tables live with 0 rows. RLS is intentionally off — Phase E turns it on when auth lands.
- Publishable API keys:
- anon (legacy JWT) —
eyJhbGciOiJIUzI1NiIs...(truncated here; full token in Supabase dashboard + MCP) - default publishable —
sb_publishable_g6uFDJjjiMG9DNDB9wt_Rg_KsB2nutR
- anon (legacy JWT) —
- Postgres connection string lives in
packages/backend/.envasDATABASE_URL(format:postgresql://postgres.xodyzfwlwvgzezwlkrqn:[password]@aws-0-eu-west-2.pooler.supabase.com:6543/postgres)..envis gitignored.
- Organization:
-
Phase C (STARTED, REVERTED, RESUMES NEXT SESSION) — services rewrite to Drizzle:
- Scope discovered: 128
db.prepare(...)call sites across 13 files (routes/workspaces, routes/environments, routes/tasks, routes/agents, routes/inbox, routes/repositories, routes/github, services/environment, services/agent, services/taskQueue, services/github, services/prMonitor, services/backlog/service, services/continuousBuild, plus src/index.ts). Plus ~20 raw-SQL call sites across the test suite (packages/backend/src/__tests__/) that seed fixtures. - Attempted this session: rewrote
db/index.tsto Drizzle + convertedroutes/workspaces.ts+routes/environments.tsas a proof-of-concept pattern. - Why reverted: mid-rewrite, main won't typecheck —
DBtype anddb.preparecalls are incompatible between SQLite (remaining 11 files) and Postgres (the 3 rewritten). No clean incremental path because data lives in one DB (flag-day cutover, not strangler-patternable). - Path for next session:
- Resume by re-doing the conversion for
routes/workspaces.ts,routes/environments.ts, anddb/index.ts. The pattern is: importDatabasefromdb/client.ts; swapdb.prepare('SELECT ...').all()fordb.select().from(table).where(...); swapdb.prepare('INSERT ...').run(...)fordb.insert(table).values({...}).returning();rowToXxxhelpers shrink since postgres-js auto-parses jsonb and returns Date objects. - Then bulk-convert in this order:
routes/repositories→routes/integrations(if exists) →routes/tasks(biggest, ~550 lines) →routes/agents→routes/inbox→routes/github→routes/backlog(mostly already delegates to services, small changes). - Then services in dep order:
services/backlog/service→services/continuousBuild→services/github→services/prMonitor→services/agent→services/environment(minimal DB) →services/taskQueue(biggest). - Update
src/index.ts—initDatabase()now returns the Drizzle client;connectSavedEnvironmentsneeds the new query shape. - Rewrite test suite:
__tests__/helpers/fakeEnvironment.ts+ everydescribeblock that seeds viadb.prepare(...). Use pglite (@electric-sql/pglitealready installed) for in-process Postgres. Expected test helper:await createTestDb()returns a Drizzle client over pglite with the migration applied; tests inject viasetDbClient(). - Drop
better-sqlite3+@types/better-sqlite3from backendpackage.json+ remove SQLite code fromdb/index.ts(thegetMigrations()+runMigrations()functions — their logic is now encoded in the Drizzle schema). - Final checks:
npm run typecheck,npm run lint,npm test --workspaces --if-present, runnpm run dev:backendlocally against Supabase to hit the health endpoint.
- Resume by re-doing the conversion for
- Estimated effort: 3-4 hours of focused editing + 1-2 hours for tests. Single session, single atomic commit (no partial commits — keeps main green until it's done).
- Don't forget:
jsonbcolumns come back as parsed objects (not JSON strings) → removeJSON.parse(row.field). Booleans come back astrue/false(not1/0) → remove=== 1checks. Dates come back asDateinstances (not ISO strings) → call.toISOString()when serializing to API responses.
- Scope discovered: 128
-
Docs landed/updated this session:
docs/CONTINUOUS_BUILD_ROADMAP.mdalready has Phase 18.1 + 18.4 (hosted backend) as #1 active — no doc change needed, just execution.- This session note.
-
Next action: start fresh session. Re-read this note. Go through Phase C step-by-step per the plan above.
Shipped the "deterministic completion" path for Continuous Build tasks plus four targeted fixes, wrote the production roadmap, and stood up a one-command VM bootstrap script.
-
Option 3 (non-interactive autonomous mode) (
packages/backend/src/services/agent.ts):- New private
isAutonomousTask(taskId)— looks up the task row, parsesmetadata.backlogItemId. True when the task was spawned by Continuous Build. startAgentbranches on this: autonomous tasks spawnclaude --print --permission-mode acceptEdits <quoted-prompt>via the existingbash -cpath inenvironment.ts(which already detectedclaude --printand runs accordingly). Process exit now = task done;handleSessionClose(code=0)transitions toawaiting_review;code !== 0transitions tofailed. No prompt trickery, no hook, no polling.- Interactive (user-launched / pr_response / pr_review / manual) tasks unchanged — still PTY-based with prompt written via
writeToSessionafter 500ms. - Prompt in
continuousBuild.ts:buildPromptrewritten: tells Claude to stop responding when done (exit is the signal); removed the "hit Ready for Review" instruction that was meant for humans.
- New private
-
Fix: SSH pty exit code (
packages/backend/src/services/ssh.ts:189,agent.ts:178):- ssh2's
stream.on('close', (code, signal) => ...)does surface an exit code; we were ignoring it and always emitting 0. Nowpty:closecarries the real exit code (or 0 if ssh2 reports null for a normal close). Agent listener forwards it tohandleSessionClose.
- ssh2's
-
Fix: scheduler env-connectivity gate (
continuousBuild.ts):- New
isSourceEnvironmentReady(source)— for SSH envs, skips sources whose env isn'tconnected. For local, always ready. Scheduler iterates sources, skips unconnected, tries next. Test covers the disconnect → connect → fire sequence.
- New
-
scripts/bootstrap-vm.sh(new):- Idempotent shell script, runnable over SSH (
ssh <host> bash -s -- [opts] < scripts/bootstrap-vm.sh). Installs Node via nvm if < 18, npm-installs@anthropic-ai/claude-code, clones the FastOwl repo, builds shared + cli + mcp-server, npm-links thefastowlbinary, writesTALYN_API_URLinto~/.bashrc(in a managed block that round-trips safely on re-run). Flags:--api-url,--branch,--install-dir,--skip-node,--skip-claude,--dry-run,--help. This is the design target for the automated "Add SSH env → install daemon" flow that lands with Phase 18.3; until then you run it manually.
- Idempotent shell script, runnable over SSH (
-
Docs:
docs/CONTINUOUS_BUILD_ROADMAP.md— the top-of-queue plan. Three ordered phases: hosted backend (18.1+18.4), daemon split + SSH auto-install (18.3), Agent SDK migration (optional, later). Definition of done for "production ready" is explicit.docs/SSH_VM_SETUP.md— fast path now front-loaded at the top pointing at the bootstrap script. Manual option kept below as fallback.
-
Tests: 64 backend → 66 backend (2 new scheduler tests: env-disconnected skip, metadata.backlogItemId written on spawn). 66 + 7 MCP + 3 CLI + 1 desktop = 77 total.
-
Project doc updates:
- Priority queue re-ordered: hosted backend now #1 (active), daemon/auto-install #2, notifications #3. Continuous Build bulk-work moved to "done above." Everything else pushed to "later."
- This session note.
Deferred: Layer-5 idle-timeout safeguard (nice-to-have — Option 3 means most timeouts are moot for autonomous tasks, only matters for interactive). Agent SDK migration (Phase 18 follow-up).
Shipped the whole "point FastOwl at a TODO doc and it builds it" feature end-to-end, covering 20.1–20.5.
-
Backlog model (
packages/backend/src/services/backlog/):parser.ts— GitHub-flavored markdown checklist parser with section scoping (#/##/###), indentation-based nesting,(blocked)/[blocked]detection, stable SHA1-based external IDs.service.ts— DB helpers +syncSource(id)which reads the file viaenvironmentService.execand upserts items in a transaction, retiring vanished items rather than deleting (preserves claimed-task linkage).- Migrations 006 (
backlog_sources+backlog_items) and 007 (repository_idon sources). - REST at
/api/v1/backlog/*(sources CRUD + sync, items list, schedule trigger).
-
Scheduler (
packages/backend/src/services/continuousBuild.ts):- New in-process domain bus at
packages/backend/src/services/events.ts.emitTaskStatusnow fires on both websocket AND domainEvents. - Subscribes to
task:status: oncompletedmarks the claimed backlog item complete; onfailed/cancelledreleases the claim; onawaiting_reviewor any terminal status, re-evaluatesscheduleNext. scheduleNextrespects workspacecontinuousBuild.enabled/maxConcurrent/requireApproval. Transactionally inserts acode_writingtask row (statusqueued), claims the item, emitstask:status.- Periodic 60s tick as safety net for missed events.
- New in-process domain bus at
-
UI (
apps/desktop/src/renderer/components/panels/SettingsPanel.tsx):- New "Continuous Build" nav section. Toggle +
maxConcurrentselect + require-approval switch. - Source manager: add markdown_file source (path + section + environment), sync button per source, delete button.
- Items preview with status chips.
- "Run scheduler" button kicks
POST /backlog/schedulefor the current workspace.
- New "Continuous Build" nav section. Toggle +
-
@talyn/cli(new workspacepackages/cli):fastowl task create|list|ready+fastowl backlog sources|sync|items|schedule+fastowl ping.- Thin fetch client (
src/client.ts) using native fetch, unwrapsApiResponse<T>, throws typedApiErroron failure. - Commander-based command setup. Env-aware defaults read
TALYN_API_URL,TALYN_WORKSPACE_ID,TALYN_TASK_ID. - README at
packages/cli/README.md, 3 client tests, wired into roottypecheck.
-
Agent env injection:
agent.tsnow builds an inlineKEY=val KEY=val claudeprefix via new exportedbuildFastOwlEnvPrefix(workspaceId, taskId, { includeApiUrl }).- For local envs,
TALYN_API_URL=http://localhost:${PORT}is included. For SSH envs it's omitted — the remote shell supplies it via.bashrc(see SSH setup doc). - Workspace/task IDs are always included so
fastowl task createworks without flags in the child session.
-
Docs:
docs/SSH_VM_SETUP.md— full end-to-end: install Claude CLI + fastowl on the VM, three networking options (SSH reverse tunnel / LAN bind / backend on VM), wire up the SSH env in the desktop app, first task, turn on Continuous Build. Troubleshooting section covers the common cases (claude: command not found,ECONNREFUSEDon child CLI calls, SSH drop).docs/CONTINUOUS_BUILD.md— feature-level walkthrough: mental model, backlog file format, task-spawns-task via CLI, "turn it on for FastOwl itself" recipe, known limitations.
-
Tests: 59 backend → 64 backend + 3 CLI = 67 total Vitest + 1 Jest smoke.
- Parser: 9 tests (flat, nesting, section scoping, stop-at-heading, blocked detection, stable IDs, blank-skip, case-insensitive heading).
- Service: 9 tests (round-trip, update, delete, syncSource add/retire/claim-preserved, nextActionableItem, skip-claimed, null-when-empty).
- Scheduler: 8 tests (disabled no-op, spawn-on-empty, maxConcurrent cap, approval hold, approval-off proceed, task-completed → item-completed, task-failed → item-released, disabled-source skip).
- Env prefix: 5 tests (API-URL default/override, task id optional, single-quote escape, SSH exclusion).
- CLI: 3 tests (unwrap success, throw on error, POST body).
- Extended
fakeEnvironmenthelper to stubexecin addition tospawnInteractiveso the backlog service's file-read path is testable without a real shell.
Deferred for 20.6: FastOwl MCP server. Deferred for 20.7: GitHub/Linear sources, priority inference, cross-source scheduling, structured depends-on annotations.
- Backend agent close (
packages/backend/src/services/agent.ts):- Clean exit (code 0) now sets task to
awaiting_reviewinstead ofcompleted(nocompleted_at) - Non-zero exit still sets task to
failed - Emits
task:statusWS event for the transition
- Clean exit (code 0) now sets task to
- New routes (
packages/backend/src/routes/tasks.ts):POST /tasks/:id/ready-for-review— stops agent, moves task to awaiting_review (agent tasks only)POST /tasks/:id/approve— awaiting_review → completedPOST /tasks/:id/reject— awaiting_review → queued for another pass
- Frontend API + hooks (
apps/desktop/src/renderer/lib/api.ts,apps/desktop/src/renderer/hooks/useApi.ts):api.tasks.readyForReview/approve/rejectclient methodsreadyForReview/approveTask/rejectTaskinuseTaskActions
- UI:
TaskTerminalnow has a primary "Ready for Review" button alongside "Stop" (stop = discard; ready = approval flow)QueuePanelTaskDetail shows "Approve" and "Reject & Requeue" buttons whentask.status === 'awaiting_review'
Deferred: git diff preview in the approval view, approval comments, push-after-approve automation, automated PR response triggering (16.3), PR review batch-post flow (16.4).
- Shared types (
packages/shared/src/index.ts):TaskTypeexpanded to'code_writing' | 'pr_response' | 'pr_review' | 'manual'- Added
AGENT_TASK_TYPESconstant andisAgentTask(type)helper
- Migration 005 (
packages/backend/src/db/index.ts):UPDATE tasks SET type = 'code_writing' WHERE type = 'automated'
- Task queue + routes (
packages/backend/src/services/taskQueue.ts,packages/backend/src/routes/tasks.ts):- Auto-processing check switched from
type === 'automated'toisAgentTask(type)(any non-manual) /tasks/:id/startnow accepts any agent task type
- Auto-processing check switched from
- CreateTaskModal:
- 4-button type picker (Code / PR Response / PR Review / Manual) with icons
- Type-specific prompt placeholder and description
- Switches between prompt-first (agent) and title-first (manual) layouts via
isAgentTask
- QueuePanel:
taskTypeConfigrenders type-specific icon + label in task list items and detail view- Replaced
isAutomatedcheck withisAgentTask(task.type)for "Start Now" button gating
Deferred for 16.2-16.5: approval gates (awaiting_review status), diff preview, automated PR Response triggering, PR Review batch-post flow, type-specific default prompt templates.
- Migration 004 (
packages/backend/src/db/index.ts):- Added
terminal_output TEXT NOT NULL DEFAULT ''column totaskstable
- Added
- Append-only task output (
packages/backend/src/services/agent.ts):handleSessionDatanow appends incoming chunks totasks.terminal_outputviaSET terminal_output = terminal_output || ?- Write cost proportional to each chunk rather than the full buffer
- Agent record is still truncated to last 10k chars; task output grows for full history
- Session close preserves the task's output (only deletes the stale agents row)
- Tasks route (
packages/backend/src/routes/tasks.ts):rowToTasknow takes optional{ includeTerminalOutput }flag — only the single-task GET pulls the full output to keep list responses small/tasks/:id/terminalfalls back totasks.terminal_outputwhen no active agent, so completed/failed/cancelled tasks still return history
- TerminalHistory component (
apps/desktop/src/renderer/components/panels/TerminalHistory.tsx):- Fetches task terminal output on mount via
api.tasks.getTerminal - Renders in read-only XTerm with collapse/expand toggle and char count
- Wired into QueuePanel TaskDetail for
completed,failed,cancelledstatuses
- Fetches task terminal output on mount via
Deferred for Phase 15.2/15.4: structured ndJson conversation log, session resume via Claude CLI, collapsible tool-use sections, history search.
- Created PR Monitor service (
packages/backend/src/services/prMonitor.ts):- Polls watched repos every 60 seconds for changes
- Tracks PR state (reviews, comments, CI status, mergeability)
- Creates inbox items for: new reviews (approved, changes requested), new review comments, new general comments, CI failures, PR becoming mergeable
- Filters out user's own comments to avoid self-notifications
- Initializes state on first poll without creating notifications
- Extended GitHub service (
packages/backend/src/services/github.ts):- Added getPRReviews, getPRReviewComments, getPRComments methods
- Added GitHubReview, GitHubReviewComment, GitHubIssueComment interfaces
- Added getConnectedWorkspaces method
- Created repository routes (
packages/backend/src/routes/repositories.ts):- GET / — list watched repos for workspace
- POST / — add watched repo
- DELETE /:id — remove watched repo
- POST /poll — force poll refresh
- Added frontend API client for repositories (
apps/desktop/src/renderer/lib/api.ts):- WatchedRepo type
- list, add, remove, forcePoll methods
- Updated WorkspaceSettings in SettingsPanel:
- Real watched repositories list from backend
- Repository selector with GitHub repo search
- Add/remove repository functionality
- Manual poll refresh button
- Created GitHub service (
packages/backend/src/services/github.ts):- OAuth authorization URL generation with CSRF state
- Code-to-token exchange
- Token storage in integrations table
- REST API methods: getUser, listRepositories, listPullRequests, getPullRequest, getCheckRuns, createPRComment
- Auto-load tokens on service init
- Created GitHub routes (
packages/backend/src/routes/github.ts):- GET /status — check configuration and connection status
- POST /connect — start OAuth flow, return auth URL
- GET /callback — handle OAuth callback, store token
- POST /disconnect — remove token
- GET /user — get authenticated user
- GET /repos — list repositories
- GET /repos/:owner/:repo/pulls — list PRs
- GET /repos/:owner/:repo/pulls/:number/checks — get CI status
- Added GitHub API client to frontend (
apps/desktop/src/renderer/lib/api.ts):- Type definitions for GitHubStatus, GitHubUser, GitHubRepo, GitHubPullRequest
- Methods: getStatus, connect, disconnect, getUser, listRepos, listPullRequests
- Updated IntegrationsSettings in SettingsPanel:
- Real-time status fetching from backend
- Connect button opens OAuth in new window
- Shows connected user (@username)
- Disconnect button to remove connection
- Proper error handling and loading states
- Configuration: Set GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_REDIRECT_URI env vars
- Created CreateTaskModal (
apps/desktop/src/renderer/components/modals/CreateTaskModal.tsx):- Form fields: title, description, type (automated/manual), priority
- For automated tasks: agent prompt and preferred environment selection
- Wired to useTaskActions hook and API
- Updated QueuePanel (
apps/desktop/src/renderer/components/panels/QueuePanel.tsx):- Wired all "Add Task" buttons to open CreateTaskModal
- Added task action buttons in TaskDetail: Queue, Unqueue, Cancel
- Actions wired to useTaskActions hook (updateTaskStatus, cancelTask)
- Created SettingsPanel (
apps/desktop/src/renderer/components/panels/SettingsPanel.tsx):- Three sections: Workspace, Integrations, Environments
- Workspace section: shows name, description, automation settings, repos
- Integrations section: GitHub, Slack, PostHog connection UI (not wired to backend)
- Environments section: list environments, test connection, delete
- Updated store to support 'settings' as activePanel
- Wired Settings button in Sidebar footer
- Fixed ESLint configuration:
- Removed broken 'erb' extends from root config
- Simplified to use eslint:recommended + @typescript-eslint/recommended
- Removed deprecated ESLint directives from main.ts, util.ts
- Fixed all unused variable errors across desktop and backend
- Added varsIgnorePattern and caughtErrorsIgnorePattern for underscore prefix
- Wired workspace settings editing:
- Added useWorkspaceActions hook with updateCurrentWorkspaceSettings
- Made auto-assign toggle and max agents select interactive in Settings
- Backend correctly handles partial settings updates (merges with existing)
- Wired agent input sending to agentService in routes/agents.ts
- Added xterm.js integration (
@xterm/xterm,@xterm/addon-fit,@xterm/addon-web-links) - Created XTerm component (
apps/desktop/src/renderer/components/terminal/XTerm.tsx):- Dark theme with proper VS Code-like colors
- Auto-resize with FitAddon
- Clickable links with WebLinksAddon
- Efficient output appending (detects incremental updates)
- Created UI components:
- Dialog, Input, Select, Textarea (
apps/desktop/src/renderer/components/ui/) - StartAgentModal (
apps/desktop/src/renderer/components/modals/StartAgentModal.tsx) - AddEnvironmentModal (
apps/desktop/src/renderer/components/modals/AddEnvironmentModal.tsx)
- Dialog, Input, Select, Textarea (
- Updated TerminalsPanel to use:
- XTerm for terminal rendering
- StartAgentModal for creating new agents
- Wired stop agent and send input functionality
- Updated Sidebar to show real environments from store with status indicators
- Added skipLibCheck to tsconfig for lucide-react compatibility
- Restructured to monorepo:
apps/desktop,packages/backend,packages/shared - Created all core types in
@talyn/shared - Built backend server with Express + WebSocket, SQLite database with migrations, REST API routes for all entities, WebSocket service for real-time events
- Added Tailwind CSS + PostCSS to renderer
- Created shadcn/ui style components (Button, Card, Badge, ScrollArea)
- Built UI shell with Sidebar (workspace selector, navigation, environment status), InboxPanel (prioritized items, actions, read/unread states), TerminalsPanel (agent list, terminal view, status indicators), QueuePanel (task list, detail view, priority badges)
- Added Zustand store for app state management
- SSH Service (
packages/backend/src/services/ssh.ts): SSH connection management via ssh2, connection pooling and auto-reconnection, PTY support for interactive terminal sessions, command execution on remote environments - Environment Service (
packages/backend/src/services/environment.ts): Manages local + SSH environments, health checking, interactive session spawning - Agent Service (
packages/backend/src/services/agent.ts): Spawns Claude CLI processes on environments, output parsing for status detection, auto-creates inbox items when agent needs attention, agent lifecycle management - Task Queue Service (
packages/backend/src/services/taskQueue.ts): Automatic task assignment to idle agents, priority-based queue processing, respects workspace maxConcurrentAgents setting - Frontend API Client (
apps/desktop/src/renderer/lib/api.ts): HTTP client for all backend endpoints, WebSocket client with auto-reconnection, real-time event handling - React Hooks (
apps/desktop/src/renderer/hooks/useApi.ts):useApiConnection,useInitialDataLoad,useAgentActions,useTaskActions,useInboxActions - App auto-detects backend availability; falls back to demo data if not running
- Created the initial context document
- Explored Electron boilerplate structure
- Reviewed PostHog's Coder devbox implementation for reference
- Established architecture decisions
- Created initial TODO list