feat: native Plane task-source + Feishu HITL notifier + worker artifact guard - #514
Draft
lefarcen wants to merge 21 commits into
Draft
feat: native Plane task-source + Feishu HITL notifier + worker artifact guard#514lefarcen wants to merge 21 commits into
lefarcen wants to merge 21 commits into
Conversation
…escalation Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s on GitHub) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…alization The provider normalization pre-pass in Normalize() round-trips each partial provider through cloneProviderConfigs (PartialProviderConfig -> ProviderConfig -> PartialProviderConfig) but reconstructed the partial with only id/kind/ baseUrl/ghPath/tokenEnv, silently dropping the new workspace/projectId fields. A valid plane provider then failed validation with "workspace/projectId is required". Carry both fields through the round-trip and add a LoadFile-path test asserting they survive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…an sit beyond Limit)
…ules, …) from loop commits Looper's worker commits everything in the worktree (agent commit + looper fallback commit via `git add -A`). On a pnpm/node repo whose .gitignore misses a build store (e.g. .pnpm-store/, a 100MB+ content store), that stages the artifact and GitHub rejects the push for exceeding the 100MB limit. Write a conservative set of build-artifact patterns to each loop worktree's git info/exclude on creation/restore. info/exclude is local to the clone and never touches the repo's tracked files or .gitignore, so it is safe and upstream-clean. The write is idempotent (no duplicate lines) and best-effort (a failure never blocks worktree creation). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add non-interactive one-click generation of a working Plane task-source config: --provider <github|plane> (default github, existing behavior unchanged) --plane-base-url / --plane-workspace / --plane-project / --plane-token-env --code-repo (or detected from --project-path git origin) --trigger-label (default looper:plan) --feishu-webhook-env (adds a notifications.webhook feishu block for any provider) For --provider plane the generated config carries a providers[] plane entry, a project bound to it (provider + GitHub code repo + local checkout), and planner/worker discovery on the trigger label (requireAssigneeCurrentUser=false since Plane assignees are UUIDs). The generated config passes load+validation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/plane-provider.md documenting the Plane task-source + Feishu HITL setup: what it is, the one-command bootstrap, env vars to export, a full config.json example, how discovery maps Plane labels to roles, and known follow-ups (discovery cache, state-group filtering, comment updates). Extend docs/configuration.md provider support to list plane and cross-link the guide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Plane task-source + Feishu HITL variant to the looper skill: a new Step 4 bootstrap block with the extra flags, the two env vars to export, and a references/plane.md with the full flag table, generated config shape, key gotchas (UUID assignees → requireAssigneeCurrentUser:false), and a discovery verify step so an agent can one-shot the setup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an "app" delivery mode to notifications.webhook: instead of an
incoming-webhook text POST, a Feishu app bot posts an interactive card to a
target group chat via the IM API. Config carries mode (webhook|app, default
webhook — unchanged), appIdEnv/appSecretEnv (env var NAMES for the app
credentials), and chatId. App mode fetches a tenant_access_token (cached with
expiry), then POSTs {receive_id, msg_type:interactive, content:<card>} to
/im/v1/messages; the card header template is orange for action_required, red
for failure, blue otherwise. Delivery is best-effort and recorded on the
"feishu_app" channel (success/failed/skipped) with the same enable/level/dedupe
gates as the webhook channel. HTTP calls are injectable for tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nfig-gated)
Foundational, additive pieces for mid-run HITL:
- domain: add awaiting_human loop status + transitions (running<->awaiting_human);
active/conflicting sets updated. No storage migration (loops.status has no CHECK).
- config: add hitl.enabled gate (default false) with partial/merge/clone; parity
fixtures updated.
- loops: HITLAsk metadata helpers (Read/Write/Clear) stored in freeform loop
metadata (no schema change) — carries question/options/sessionId/answer.
- api: POST /api/v1/loops/{seq}/respond validates the loop is awaiting_human,
stores the human answer on HITL metadata, then transitions awaiting_human->running
(requeue + scheduler tick) so the next claim resumes the run. Testable core.
- discovery: skip awaiting_human loops in planner/worker re-queue guards (safe no-op
when HITL is off, since nothing can reach that status).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ated)
Wire the mid-run HITL loop into the worker runner, fully gated by hitl.enabled
(default off = byte-for-byte unchanged behavior):
- prompt: append an ask instruction only when HITL is on.
- suspend: after the agent turn, consume a .looper/ask.json sentinel; if present,
capture the agent's native session id, persist the ask on loop HITL metadata,
transition the loop to awaiting_human, cancel the claimed queue item, end the
run as interrupted (resumable), and send an ask-card.
- resume: on the next run, inject the human's answer into the prompt AND set
NativeResumePrompt + NativeSessionID so native-resume vendors (codex/claude/
opencode/cursor) continue the SAME session; falls back to a fresh turn with the
answer in-prompt otherwise. Worker AgentRunInput + adapter now carry the
native-resume fields.
- notifier: SendHITLAsk builds an interactive Feishu app-bot card with one button
per option, each value carrying {loopSeq, answer} for the listener.
- scheduler wires HITLEnabled=cfg.HITL.Enabled + HITLNotify->SendHITLAsk.
Session resume was already supported by internal/agent/executor.go
(NativeSessionID/NativeResumePrompt); this reuses it. Tests: consumeAskSentinel,
suspendForHuman transition+notify, and the ask-card buttons.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-gated)
Add the thin receive-side of the HITL bridge:
- extract deliverHumanAnswer() as the shared respond core (validate
awaiting_human, store answer, transition running) used by both the
/respond API and the Feishu receiver.
- POST /api/v1/hitl/feishu: gated by hitl.enabled, answers Feishu's
url_verification challenge, and on a card-action button click maps
value.{loopSeq,answer} -> the awaiting loop -> deliverHumanAnswer in-process.
Transport choice: card-action WEBHOOK RECEIVER over looper's existing HTTP
server (point the Feishu app's card-callback URL at <daemon>/api/v1/hitl/feishu)
rather than the larksuite long-connection WS SDK, to avoid a heavy new
dependency. Typed free-text message events are a documented future extension;
button clicks work today.
Tests: card-action delivers the answer + resumes; challenge echo; gated 403
when hitl.enabled is false.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Feishu notifier commit added a Webhook field to NotificationConfig, so GET /config now emits notifications.webhook. Update the frozen contract fixture to match; this was failing since before the mid-run HITL work.
Codex prints its session id as a styled human line ("\x1b[1msession id:\x1b[0m
<uuid>") with a space in the key, so extractNativeSessionID — which only looked
for JSON-style keys like session_id — never captured it. Native session resume
then silently degraded to a fresh session with the answer in the prompt.
Strip ANSI escapes before matching and add the "session id" key. This makes
mid-run HITL actually resume the same Codex session, and also fixes the
reviewer's re-review-on-head-change native resume for Codex.
Two correctness bugs found in review of the mid-run resume path: 1. persistPullRequestReference merged PR fields into the STALE passed-in loop metadata, then wrote it over the fresh record read inside the same tx — so the 'consumed' flip made earlier in the resumed run was silently reverted to 'answered'. A later re-run of the loop would then re-inject the already-used human decision. Merge into the fresh in-tx metadata instead. 2. The answer was marked 'consumed' BEFORE the resumed agent turn ran. If that turn timed out and retried, the retry saw 'consumed', returned no resume prompt / session, and silently dropped the human's decision. Split into a read-only pendingHumanAnswer (used before the turn) + markHumanAnswerConsumed (called only after the turn completes), so a failed turn re-reads the answer and a successful one never re-injects it.
…ity)
The /hitl/feishu card-action route delivered human-authored text straight into
an awaiting_human loop's coding-agent session with no origin check — with the
default authMode:none, anyone who could reach the daemon could POST
{loopSeq, answer} and inject an arbitrary decision into any parked loop
(authMode can't help: Feishu's servers can't send a looper Bearer token).
Gate the route on the Feishu app Verification Token, echoed in every callback:
add notifications.webhook.verificationTokenEnv (an env var NAME, since looper is
open source), and require a configured + constant-time-matching token before
delivering an answer. Fail closed: unconfigured token => 403, mismatch => 401.
The url_verification handshake still echoes the challenge (and also enforces the
token when one is configured) so app setup works.
…ecurity - detectHumanAsk now warns when the ask sentinel exists but can't be read, instead of silently proceeding to PR as if the agent never asked. - docs/plane-provider.md: add the interactive HITL section — app-bot card mode, the mid-run ask/answer/resume flow, the /hitl/feishu vs /respond answer paths, and the fail-closed verification-token requirement + Encrypt-Key-off + no-option caveats.
Previously every app-bot message was an independent interactive card, so a task's ask + PR-opened stacked as separate cards with no grouping. - Aggregate per task: the first message for a loop posts a plain-text '开始处理 #N …' root, and every later message for that loop is sent as a reply_in_thread under it, so a task collapses into one Feishu thread. Root ids are cached in-memory per loop on the (long-lived) gateway. - Only mid-run questions stay interactive cards (they need buttons); PR-opened / progress / completion updates are now plain text (Feishu auto-links URLs). - Thread key is the loop id, plumbed onto HITLAskCard so asks and system notifications thread together. Removes the now-unused buildFeishuCard. Tests updated + a threading test added.
A mid-run question blocks on a human, so it should ping them rather than sit unread in a busy group. Add notifications.webhook.mentionOpenIds (Feishu open_ids, plain ids not secrets); SendHITLAsk renders them as card <at id=…> tags above the question. Empty -> no mention (unchanged).
…he agent Until now a human could only answer a mid-run ask by clicking an option button (or the /respond API) — typing a free-text reply in the Feishu thread went nowhere. Add the inbound half of true two-way HITL: - Persist the loop<->thread-root mapping (new feishu_threads table + repo) so the gateway threads a task's messages AND the callback can reverse-map a reply back to the loop. Replaces the gateway's in-memory root map (also survives restart). - /hitl/feishu now also handles im.message.receive_v1: a text reply typed in a known ask thread is reverse-mapped to its loop and delivered as the answer via the same deliverHumanAnswer path (verification-token gated; header.token for v2). - Non-answer chatter (unknown thread, non-text, loop not awaiting) is ignored with 200 so Feishu stops retrying and no loop is touched. Migration 0018 + regenerated legacy fixture; schema-version pins bumped.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds native Plane task-source support + a Feishu/generic HITL notifier + a worker build-artifact guard, so Looper can consume tasks from a Plane project (Plane = source of truth, no GitHub issue duplicated), open the implementation PR on GitHub, and ping a webhook when a human is needed.
Draft — bundles 3 logically-separable pieces (happy to split into focused PRs; grouped here because they were built + e2e-validated together). Reviews/structure welcome.
What's in it
feat(notify)— webhook notification channel (Feishu/generic). New delivery channel in the notification gateway; fans out on existingaction_required/failure/PR-ready notifications (scheduler untouched). URL injected via an env var named in config (urlEnv) — no secrets in config.format: "feishu"|"generic". Turns Looper's silent "waiting"/"needs attention" into an active ping.feat(forge)— native Plane task-source provider.kind: "plane"provider: issues/labels/comments/assignees read from Plane's REST API; a plane project's PRs/code stay on GitHub (a plane project'srepois a GitHub repo, so PR-side falls through to the GitHub gateway — no deep two-provider rethreading). Plane creds viatokenEnv. Mapssequence_id→issue number, resolves label UUIDs→names, custom UA (Cloudflare), token redaction.fix(config): preserveworkspace/projectIdthrough provider normalization.fix(forge):ListOpenIssuesfetch-all-then-filter (a label match can sit beyondLimit).feat(worktree)— exclude common build artifacts from loop commits. Writes.pnpm-store/,node_modules/,dist/, … to each worktree'sinfo/excludeon creation, so neither the agent's commit nor the fallback commit stages them (an impl push failed pushing a 170MB.pnpm-store/). Worktree-local; never touches the repo's tracked files or.gitignore.Validated end-to-end
Ran looperd against a Plane project: it natively discovered a Plane work-item (no GitHub issue), the worker implemented it with codex, committed cleanly (no build artifacts), pushed, and opened a clean single-file impl PR — with the Feishu notifier delivering events throughout.
Known follow-ups (non-blocking, called out honestly)
ListOpenIssueshardcodesState:"open"(doesn't drop completed/cancelled via Plane state-groups; relies on the trigger label). Needs a fail-open state-group mapping.internal/apiconfig.get, expects specific tool paths) fails independently of this branch.🤖 Generated with Claude Code