Autonomous Cron: MR/Issue Triage + Self-Driven Brainstorming - Design¶
Date: 2026-06-11 Status: Design (approved, pre-plan) Builds on: 2026-06-09-scm-projects-pr-reactions-design.md (deployed, operator 0.3.0)
1. Goal¶
Make tatara act autonomously on a schedule, not only in reaction to webhooks:
- Periodically triage open MRs/PRs in a Project and act per author (tatara-authored: fix/merge/close; human-authored: comment/suggest/approve/requestChanges).
- Periodically triage open issues (per-repo issues + project board items) and either implement them (open a PR, author-gated downstream) or close them with an explanatory comment.
- Optionally, self-drive the project: research docs + the tatara-memory graph + the live internet, and propose new issues (carrying the existing awaiting-approval marker).
All three are cron-driven, with a configurable per-cycle pick limit.
2. Non-goals¶
- No new agent runtime. Work is performed by the existing claude-code-wrapper pod via the existing Task turn loop.
- No change to the existing human-vs-tatara write-back verb matrix. Cron is a Task factory; it routes to existing Kinds.
- No K8s CronJob and no new CRD. Scheduling reuses the in-repo
repository_controllercron pattern. - Internet research is scoped to brainstorming only. Extending it to implement Tasks (library-doc lookup) is explicitly out of scope here.
- No board/issue sync engine. The SCM remains the source of truth; tatara reads on a schedule and writes via the audited egress path.
3. Architecture¶
Three cron-driven scans per Project, each a Task factory. The operator (which holds the SCM token and is the sole SCM-write caller) lists open work via new SCM read capabilities, applies selection + dedup, and creates Tasks up to per-activity caps. Tasks flow through the unchanged wrapper-pod turn loop and the unchanged write-back matrix.
Project reconciler (cron fires)
-> projectscan.go: for each due activity
-> scm.Client.List* (operator egress, one token)
-> select (priority-then-stale), cap at maxPerCycle
-> dedup vs existing Project-owned Tasks
-> create Task{Kind, Source}
-> RequeueAfter = soonest next-fire of the three crons (clamped)
TaskReconciler (unchanged loop)
-> spawn wrapper pod -> agent turns -> MCP intent
-> writeback.go (unchanged matrix, author/actor gate at egress)
Identity and gating are inherited verbatim from the SCM-projects feature: the webhook/payload author is a hint only; the authoritative bot-authorship gate is enforced at egress in the TaskReconciler via GetPRState (see memory scm-author-vs-actor-egress-gate). Cron-created Tasks pass through the same gate, so a cron-picked human MR can never be auto-merged/closed.
4. Scheduling¶
Reuse the existing repository_controller.go mechanism exactly:
robfig/cron/v3cron.ParseStandardon 5-field cron strings.- Per-activity
Status.Last*Scan *metav1.Timerecords the last fire; next-fire computed frombase = lastScan | creationTimestamp. RequeueAfterset to the soonest of the three next-fires, clamped tomaxScheduleRequeue(6h, reused constant) so clock skew or long sleeps still converge.- A malformed cron string logs an error and disables that activity (does not crash the reconciler), mirroring
scheduleNextReingest.
Scan logic lives in a new file internal/controller/projectscan.go, called from ProjectReconciler.Reconcile (which already returns RequeueAfter at project_controller.go:93). The reconciler keeps Project lifecycle; the scan file owns list/select/dedup/create.
5. The three activities¶
5.1 MR-triage¶
- Scan:
ListOpenPRs(ctx, owner, repo)across the Project's enrolled repos (GitHub) or the bound project (GitLab). - Per item: call
GetPRState(authoritative author). Create a Task: - author ==
BotLogin->Kind=selfImprove - author !=
BotLogin->Kind=review - No new egress verbs. The Task drives the existing matrix:
review(human MR): comment / suggest / approve / requestChanges. Never auto-close.selfImprove(tatara MR): improve code / fix conflicts / merge-per-policy / close.
"Fix+merge or close" therefore applies only to tatara-authored MRs, exactly matching the human-vs-tatara contract from the prior feature.
5.2 Issue-work¶
- Scan:
ListOpenIssues(ctx, owner, repo)(per-repo) andListBoardItems(ctx, board)(project board). Board items that are issues are de-duplicated against the per-repo issue list by(repo, number). - Per item: create a Task
Kind=triageIssue,Source= the issue. - The agent researches project direction (docs + memory), then emits exactly one
issue_outcome: implement: the same pod continues into the normal implement flow and opens a PR. The PR is tatara-authored, so it re-enters the author-gated review/merge path (merge governed by the existingmergePolicy). Theissue_outcome{implement}call is a recorded marker; the PR is the artifact.close: operator callsCloseIssue(number, comment)with the agent's explanatory comment.
This is the gated "implement-or-close" analog of MR fix-or-close. New issues are never silently force-merged: an implement decision still produces a PR that obeys mergePolicy (afterApproval | autoMergeOnGreenCI).
5.3 Brainstorm (opt-in)¶
- Enabled only when
spec.scm.cron.brainstorm.enabledis true. - Scan: generative, no list. Create a Task
Kind=brainstormwithmaxPerCyclebounding issues proposed per cycle. - The agent researches per
sources(subset ofdocs,memory,internet), then emits one or more existingpropose_issuecalls. Proposed issues carry the existing awaiting-approval marker (approvalLabel), so a human must approve before any implementation. internetinsourcesrequires the pod to have outbound network + WebSearch/WebFetch (see section 9).
6. CRD changes¶
Project.spec.scm (extends the existing ScmSpec):
scm:
# ... existing: provider, owner, botLogin, board, mergePolicy,
# prReactionScope, approvalLabel ...
priorityLabel: "tatara/priority" # selection: items with this label first
cron:
mrScan:
schedule: "0 * * * *" # 5-field cron; empty disables
maxPerCycle: 1
issueScan:
schedule: "0 * * * *"
maxPerCycle: 1
brainstorm:
enabled: false
schedule: "0 6 * * *"
maxPerCycle: 1
sources: [docs, memory, internet]
Go types (in api/v1alpha1/project_types.go):
type CronActivity struct {
Schedule string `json:"schedule,omitempty"`
MaxPerCycle int `json:"maxPerCycle,omitempty"` // default 1, clamped >= 1
}
type BrainstormActivity struct {
Enabled bool `json:"enabled,omitempty"`
Schedule string `json:"schedule,omitempty"`
MaxPerCycle int `json:"maxPerCycle,omitempty"`
// +kubebuilder:validation:items:Enum=docs;memory;internet
Sources []string `json:"sources,omitempty"`
}
type ScmCron struct {
MRScan CronActivity `json:"mrScan,omitempty"`
IssueScan CronActivity `json:"issueScan,omitempty"`
Brainstorm BrainstormActivity `json:"brainstorm,omitempty"`
}
// added to ScmSpec:
// PriorityLabel string `json:"priorityLabel,omitempty"`
// Cron *ScmCron `json:"cron,omitempty"`
ProjectStatus adds:
LastMRScan *metav1.Time `json:"lastMRScan,omitempty"`
LastIssueScan *metav1.Time `json:"lastIssueScan,omitempty"`
LastBrainstorm *metav1.Time `json:"lastBrainstorm,omitempty"`
Task.spec.kind enum extends from implement;review;selfImprove to implement;review;selfImprove;triageIssue;brainstorm.
Empty cron (or empty schedule per activity) keeps the feature dormant - safe default for the existing live Project, which has no spec.scm at all.
7. Selection: priority-then-stale¶
For a list of candidates and a maxPerCycle N:
- Partition into
withPriority(carriespriorityLabel) andrest. - Within each partition, sort least-recently-touched first (PRs/issues: by
updatedAtascending; board items: by board updatedAt). - Concatenate
withPriority ++ rest, take first N (after dedup filtering, section 8).
If priorityLabel is empty, the partition is pure stale-first.
8. Dedup: no re-pick¶
Tasks are the dedup record - no growing status map. Cron-created Tasks carry labels:
tatara.io/source-repotatara.io/source-numbertatara.io/source-kind(review | selfImprove | triageIssue)tatara.io/head-sha(PRs only)
Scan procedure per candidate:
- PR: skip if a non-terminal Task exists for
(repo, number); skip if a terminal Task exists at the currentHeadSHA(already handled this revision). A new push (newHeadSHA) makes it eligible again. - Issue: skip if a non-terminal Task exists for
(repo, number); for terminal Tasks, skip unless the issue'supdatedAtis newer than the Task's creation (new activity warrants re-triage). Closed issues are never reopened (they do not appear inListOpenIssues). - Brainstorm: no per-item dedup;
maxPerCyclebounds output. The agent is instructed to check existing open issues (viaListOpenIssuesresults passed in the prompt, or its ownpropose_issueidempotency) to avoid duplicate proposals.
Terminal = Task phase in {Succeeded, Failed}. Tasks have TTL-based GC (existing), so the label-selected set stays bounded.
9. Web egress (brainstorm internet)¶
sources: [..., internet] requires the brainstorm pod to reach the internet and have WebSearch/WebFetch enabled.
- Chart bakes nothing (rule 14). The operator stamps a pod label (e.g.
tatara.io/egress: internet) on brainstorm pods wheninternetis insources. - The cluster-side NetworkPolicy (infra helmfile) keys on that label to allow egress. Pods without the label get the default-deny egress.
- No per-task permission-mode switch: the default
PermissionMode=bypassPermissionsalready allows WebSearch/WebFetch (claude built-ins). The only new knob is the egress label; without it the NetworkPolicy blocks outbound regardless of tool availability.
10. New SCM capabilities¶
Added to scm.Client (and both implementations):
type PRRef struct {
Repo string
Number int
Author string
HeadSHA string
Labels []string
UpdatedAt time.Time
}
type IssueRef struct {
Repo string
Number int
Labels []string
UpdatedAt time.Time
IsPR bool // GitHub returns PRs in the issues list; filter these out
}
type BoardItem struct {
Repo string
Number int // 0 for draft/non-issue items (skipped)
Column string
UpdatedAt time.Time
}
ListOpenPRs(ctx, owner, repo string) ([]PRRef, error)
ListOpenIssues(ctx, owner, repo string) ([]IssueRef, error)
// board is the Project's existing BoardSpec (githubProjectNumber | gitlabBoardId).
ListBoardItems(ctx context.Context, board BoardSpec) ([]BoardItem, error)
CloseIssue(ctx context.Context, repo string, number int, comment string) error
- GitHub:
ListOpenPRs/ListOpenIssuesvia REST v3 (GET /repos/{o}/{r}/pulls?state=open,GET /repos/{o}/{r}/issues?state=openwithIsPRfiltering onpull_requestpresence).ListBoardItemsvia GraphQL ProjectV2 (user-scoped per the prior dual user/org fix).CloseIssue= RESTPATCH state=closed+ a comment POST. - GitLab: REST
GET /projects/:id/merge_requests?state=opened,GET /projects/:id/issues?state=opened; board items via the label-board mapping;CloseIssue=PUT state_event=close+ a note POST.
11. New MCP tool: issue_outcome¶
Mirrors the existing pr_outcome tool, added to tatara-cli and registered by the wrapper.
- Validated enum on
action;commentrequired forclose. - Emits intent to the operator REST endpoint
POST /tasks/{t}/issue-outcome; the TaskReconciler performs the SCM write (CloseIssue) onclose, or records the marker onimplement.
propose_issue, review_verdict, pr_outcome are reused unchanged. Tool count on the cli MCP server goes 12 -> 13.
12. Egress verb matrix (delta only)¶
Unchanged from the prior feature except:
| Kind | New verbs used |
|---|---|
| triageIssue | CloseIssue (on close), existing OpenChange/Comment (on implement) |
| brainstorm | existing CreateIssue + AddLabel (awaiting-approval) via propose_issue |
MR-triage adds no verbs. The author/actor authoritative gate (GetPRState().Author == BotLogin before any selfImprove pod spawn and before any Merge/ClosePR) is unchanged and now also covers cron-created selfImprove Tasks.
13. Observability¶
Per-activity Prometheus metrics (operator):
- Counters:
tatara_scan_items_total{activity, outcome=scanned|picked|skipped_dedup|skipped_cap}. - Counter:
tatara_scan_tasks_created_total{activity, kind}. - Histogram:
tatara_scan_duration_seconds{activity}. - Gauge:
tatara_tasks_inflight{kind}(extends existing in-flight gauge to the new Kinds). - Counter:
tatara_issue_outcome_total{action}.
Every scan logs at INFO with project, activity, listed, picked, skipped, duration_ms. Errors listing/closing log at ERROR with the SCM HTTP status.
14. Testing¶
- envtest: cron fire computes correct next-fire from
Last*Scan; malformed cron disables activity without crashing; scan creates Tasks with correct Kind + labels; dedup skips in-flight and same-SHA-handled; selection orders priority-then-stale and caps atmaxPerCycle. - Fake SCM client:
ListOpenPRs/ListOpenIssues/ListBoardItems/CloseIssuetable-driven; GitHub issue list filters PRs viaIsPR. - author/actor: cron-created Task for a human-authored PR resolves to
Kind=reviewand the egress gate refuses merge/close even if a stale label suggested tatara authorship. - cli:
issue_outcomemarshal + enum-validation test; missing comment oncloserejected. - wrapper: build-stage guard asserts
issue_outcomeis registered in the baked cli binary (same pattern as the prior tool-registration guard). - Contract-lock doc (
2026-06-11-autonomous-cron-contract-lock.md) freezes the wire types (PRRef/IssueRef/BoardItem,issue_outcomeschema, REST endpoint, CRD fields) before parallel build, per the lesson that cross-repo wire divergence is caught late.
15. Cross-repo split (build order)¶
Mirrors the prior feature. Operator contract first; cli/wrapper follow; infra last.
- tatara-operator (largest):
projectscan.goscan loop + cron wiring in Project reconciler; SCM list+close capabilities (interface + GitHub + GitLab); CRD additions (ScmCron, BrainstormActivity, priorityLabel, Last*Scan, new Task Kinds) + regen;triageIssue/brainstormTask handling;issue_outcomeREST endpoint + TaskReconciler write; egress pod label for internet brainstorm; metrics; tests. Chart appVersion bump. - tatara-cli:
issue_outcomeMCP tool + marshal/enum test. Version bump. - tatara-claude-code-wrapper: register
issue_outcome; image bump to the new cli; build-stage guard test. Chart bump. - infra (runbook, user-gated): add the
spec.scm.cron+priorityLabelblock to the Project CR; add the egress NetworkPolicy keyed ontatara.io/egress: internet; operator image roll. sops/account steps unchanged from the prior runbook.
16. Open risks¶
- Brainstorm duplicate issues: mitigated by passing existing open issues into the agent prompt and
maxPerCycle, but the agent could still propose near-duplicates. Acceptable: proposals are gated behind human approval, so a duplicate is a cheap reject. - Scan cost at scale: listing every repo every cron tick is O(repos). Bounded by enrolled-repo count (small) and the 6h clamp; revisit if repo count grows large.
- Internet egress blast radius: confined to brainstorm pods via the label-gated NetworkPolicy; default-deny everywhere else.