* feat(tracker): GitHub issue intake observer
Adds an opt-in daemon poll loop that spawns one worker session per
eligible open GitHub issue, keyed by the canonical "github:<native>"
id so restarts cannot double-spawn. Eligibility requires an explicit
label or assignee rule to avoid draining an entire backlog.
Provider dispatch goes through a TrackerResolver interface
(SingleTrackerResolver for now) so Linear/Jira can be added later as
new adapters plus a resolver-map entry, without touching the poll,
eligibility, or backoff logic. Reuses the existing rate-limit-aware
GitHub tracker adapter and backs off per-project on any poll failure
(including rate limits) instead of retrying in a tight loop.
Closes#2324.
* feat(frontend): surface GitHub tracker intake in project settings
Adds a Tracker Intake card to project settings (enable, repository
override, labels, assignee) with inline validation requiring at
least one label or assignee before intake can be saved. Session
cards and the inspector show the canonical "github:<native>" issue
id when a session was spawned by intake.
Regenerated frontend/src/api/schema.ts against the backend's
TrackerIntakeConfig. The provider is currently fixed to "github";
adding a picker for future providers is additive once the backend
enum grows.
Closes#2324.
* fix(tracker): start the intake observer unconditionally at boot
startTrackerIntake scanned projects once at daemon startup and skipped
starting the observer loop entirely when none had intake enabled yet.
Poll() already re-reads every project's config on each tick and skips
disabled projects there, so the boot-time gate only broke the common
case: enabling intake on a project after the daemon is already running
silently never got picked up until the next restart, with no error or
log line to explain why.
Always start the loop; the adapter (and its token resolution) stays
lazy regardless, so there's no added cost when intake is unused.
Found while manually verifying issue intake end-to-end against a live
dev daemon: enabled intake via the settings UI, saved successfully,
but no session ever spawned for a matching labeled issue.
* fix(frontend): show auto-detected repo link, hide labels input for now
The Electron app only registers git projects today, so the daemon
always has a usable git origin to derive owner/repo from when
trackerIntake.repo is unset (trackerRepo() in observer.go, already
covered by every Poll-level test in observer_test.go). Replace the
manual Repository input with a read-only link derived client-side
from the project's own git origin, purely for display — the daemon's
own derivation at poll time is unaffected either way.
Comment out the Labels input; Assignee is the only intake eligibility
rule editable from this form for now. form.intakeLabels/intakeRepo
stay wired into buildIntake so a value set via the CLI round-trips
on a UI save instead of being silently cleared.
* chore: format with prettier [skip ci]
* feat(frontend): offer issue intake at project creation
Add the GitHub tracker intake controls to the create-project sheet so a
project can opt into issue-driven worker spawning at creation time, not
only later via Settings.
- Extract the shared IntakeFields component (enable toggle, assignee
input, validation, credential hint) plus buildIntake/intakeNeedsRule/
deriveGitHubRepo helpers into IntakeFields.tsx, and consume it from
ProjectSettingsForm so the two surfaces can't drift.
- CreateProjectAgentSheet renders IntakeFields (no repo-preview row,
since the git origin isn't known there; the daemon derives the repo).
The selection now carries an optional trackerIntake payload, gated by
the same "requires a label or assignee" rule the backend enforces.
- Thread trackerIntake through Sidebar's onCreateProject into the
POST /api/v1/projects config. No backend change: the endpoint already
accepts a full ProjectConfig and the intake observer picks up newly
enabled projects on its next tick.
Verified in the web preview: enabling reveals the assignee field and
gates submit until a rule is set; submit builds the intake payload.
* chore: format with prettier [skip ci]
* feat(frontend): simplify intake controls in the create-project sheet
Reduce the create sheet's tracker-intake block to the essentials: the
enable toggle plus an info icon whose hover tooltip explains what
enabling does, then the assignee input. Drop the descriptive intro
paragraph, the inline "requires a label or assignee" guard text, and
the credential hint — the sheet stays minimal and submit gating already
communicates the missing rule via the disabled button.
- IntakeFields gains a `compact` prop: hides the prose, folds the
explanation into an Info tooltip, and drops the trailing help text.
The tooltip is wrapped in its own TooltipProvider so the component is
self-contained regardless of ancestor. The verbose settings card is
unchanged (renders without `compact`).
- Assignee placeholder reworded to "type username or * for any".
Verified in the web preview: the info tooltip surfaces the one-line
description on hover, the assignee placeholder is updated, and no other
copy remains in the create sheet.
* perf(tracker): cache GitHub issue lists with ETag conditional requests
The intake observer polls Tracker.List for the same repo+filter every
tick, and each poll did an uncached GET + full JSON decode even when
nothing changed. Add HTTP conditional requests so unchanged polls are
cheap and don't consume the primary rate limit.
- Tracker holds a per-request-path cache of {etag, mapped issues},
guarded by a mutex. The key space is bounded by intake-enabled
repo/filter pairs, so no eviction is needed.
- List sends If-None-Match with the cached ETag (verbatim, preserving
weak validators). On 304 it returns the cached issues without
re-decoding (GitHub 304s don't count against the primary rate limit);
a rotated ETag on the 304 is recorded. On 200 it stores the new
{etag, issues}, or drops a stale entry if the response omits an ETag.
A 304 without a prior validator falls back to an unconditional refetch.
- Extract the HTTP round-trip into roundTrip(); do() delegates to it so
Get and Preflight keep byte-for-byte identical behavior. roundTrip
owns If-None-Match, ETag capture, and 304 handling before
classifyError.
Tests cover revalidation returning cached issues on 304, ETag rotation
on change, separate cache keys per filter, and no caching when the
response carries no ETag.
* feat(frontend): trim tracker intake copy in project settings
Reduce the settings Tracker Intake card to a one-line description
("Auto-spawn worker sessions from matching tracker issues.") and drop
the trailing credential/daemon-restart hint. The compact create sheet is
unaffected (it already renders neither).
* feat(tracker): scope v1 intake to assignee-only
Per PR review, labels were a persisted/API/CLI eligibility rule while the
UI only ever exposed assignee — surface beyond the intended v1 scope.
Remove labels from intake end-to-end; assignee becomes the required and
only eligibility rule.
- domain: drop TrackerIntakeConfig.Labels; Validate() now requires a
non-empty assignee when enabled (was "labels or assignee").
- observer: pass only State+Assignee into ListFilter; drop the label
match loop in issueMatchesConfig.
- CLI: remove --tracker-label and its plumbing.
- Regenerate openapi.yaml + schema.ts (labels gone from the schema).
- frontend: drop labels from IntakeForm/buildIntake/intakeNeedsRule and
the settings form state; validation copy now "requires an assignee".
Remove the label round-trip test; keep assignee coverage.
The generic domain.ListFilter.Labels adapter capability is retained;
only intake stops using it.
* fix(tracker): paginate GitHub issue listing with page-aware ETag cache
Per PR review, single-page listing is a correctness bug for intake, not
just a bounded v1 tradeoff: with more than a page of eligible open
issues, AO re-sees the same first page every poll, the persisted
issue_id dedupe skips the already-spawned first-page issues, and later
pages are never fetched or spawned.
- List now requests per_page=100 and follows the GitHub Link header
rel="next" until no next link remains, accumulating issues across all
pages. A maxListPages guard fails loud on a pathological Link cycle.
- The ETag cache is page-aware: each entry stores {etag, issues,
nextPath} keyed by the per-page request path, so a 304 Not Modified
still continues to the cached next page. roundTrip now surfaces the
parsed next path alongside the ETag; do() delegates unchanged so
Get/Preflight keep identical behavior.
- ListFilter.Limit becomes an optional total-result cap (page size is
fixed at the provider max).
Tests cover multi-page accumulation, all-304 revalidation continuing the
cached chain, page-count shrink orphaning a stale entry, and Link
header parsing.
* style(session_manager): gofmt manager_test.go to unblock CI
The session-restart test carried a struct-literal alignment that gofmt
(and golangci-lint's goimports) reject, failing the build-test and lint
jobs. Whitespace-only reformat; no test logic changes.
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: whoisasx <adil.business4064@gmail.com>
* feat: add workspace project registration schema
* fix: satisfy workspace registration lint
* fix: harden workspace registration edge paths
- Reject linked-worktree and bare parents via validateWorkspaceParent before any mutation
- Roll back git init/.gitignore on failure in initWorkspaceParent so retries are clean
- Reject child repos named __root__ (reserved PK in session_worktrees)
- Serialise Service.Add with addMu to eliminate TOCTOU on concurrent same-path calls
- Fix ensureWorkspaceGitignore permission 0o600 -> 0o644
- Improve guardNoGitlinks suggestedFix with actionable git rm --cached guidance
- Remove dead CASE/__root__ ordering from ListWorkspaceRepos SQL (regenerated via sqlc)
- Resolve RepoOriginURL once per code path in Add (workspace vs single-repo)
- Add 7 tests covering the new edge paths
* feat(config): persist per-project agent config and resolve it at spawn
Each project can now carry its own agent config (model, permissions,
adapter-specific keys) that survives daemon restart and is resolved into
the launch command when a session spawns.
- storage: add nullable projects.agent_config JSON column (migration 0008);
marshal/unmarshal in the store so the domain carries map[string]any
- resolution: session manager loads the project row and populates
LaunchConfig.Config before GetLaunchCommand
- validation: claude-code declares a ConfigSpec (model, permissions) and
rejects unknown keys / bad types / bad enums at spawn; it applies the
model override and config-driven permission mode (explicit Permissions
still wins)
- surface: PUT /projects/{id}/agent-config + `ao project set-config`
(--set/--config-json/--clear), config shown in `ao project get`
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(claudecode): validate string-list/required config keys and unhandled types
Address review on per-project agent config validation:
- handle ConfigFieldStringList (list of strings) explicitly
- reject unhandled ConfigFieldType via a default case rather than
silently passing
- enforce Required fields are present
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(config): make per-project agent config a typed struct
Replace the free-form map[string]any agent config with a typed
domain.AgentConfig{Model, Permissions} so values are validated when set
(CLI/API) instead of silently dropped at spawn, and the OpenAPI/TS schema
and UI get real typed fields.
- domain: AgentConfig struct + Validate(); PermissionMode moves to domain
and ports re-exports it as a type alias (zero adapter churn)
- storage: marshal/unmarshal the typed struct (IsZero → SQL NULL)
- service: validate on Add and SetAgentConfig; read-model exposes a typed
*AgentConfig
- claudecode: read typed cfg.Config.Model/.Permissions; drop the
map/spec-based validateConfig in favor of the typed Validate()
- cli: typed `ao project set-config --model/--permission/--clear`
- docs: add docs/design/per-project-config.md blueprint sequencing the
remaining # Projects fields toward fully typed per-project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(config): full typed per-project ProjectConfig (store, resolve, surface)
Expand per-project config from agentConfig-only to the full legacy
`projects.<id>` surface, modeled as one typed domain.ProjectConfig
persisted in a single projects.config JSON column.
Wired end-to-end at spawn:
- defaultBranch → base branch for the session worktree (ports.WorkspaceConfig.BaseBranch)
- env → merged into the runtime env (AO-internal vars still win)
- symlinks → repo files linked into the workspace
- postCreate → commands run in the workspace (OS-agnostic shell)
- agentRules / agentRulesFile / orchestratorRules → merged into the prompt
- worker/orchestrator role overrides → harness + agent-config resolution
Stored + validated + surfaced now, consumption deferred (no consumer yet):
tracker, scm(+webhook), opencodeIssueSessionStrategy; sessionPrefix feeds
the display prefix only (session-id generation unchanged).
Validation lives on domain.ProjectConfig.Validate() and runs when config is
set (CLI/API). PermissionMode/AgentConfig stay typed; harness names validated
via domain.AgentHarness.IsKnown().
Surface: PUT /projects/{id}/config (replaces /agent-config) + typed
`ao project set-config` flags (--default-branch/--env/--symlink/--post-create/
--agent-rules/--worker-agent/… or --config-json). OpenAPI + TS regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(lint): tighten symlink dir perms to 0o750 (gosec G301)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(config): centralize default project config + tests
Add domain.DefaultProjectConfig / ProjectConfig.WithDefaults with a single
DefaultBranchName ("main") source of truth, replacing the literal "main"
scattered in the read-model and the gitworktree adapter. Unconfigured
projects now resolve the default branch through one path; every other field
defaults to its zero value.
Tests: defaults present for all fields (DefaultProjectConfig/WithDefaults),
and an unconfigured project reports the default branch + derived session
prefix while omitting the empty config object.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(config): encode documented defaults (branch=main, tracker=github)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(config): fail-safe paths for missing/corrupt per-project config
Address review on default-config / fail-safe spawning:
- projectRules: a missing AgentRulesFile is optional context, skipped
rather than aborting every spawn (only a real read error surfaces)
- store: a corrupt config JSON column degrades to a zero config instead
of failing GetProject/ListProjects/FindProjectByPath for that row
- restore: re-apply the project's resolved AgentConfig so a configured
model/permissions carry across a restore (matches fresh spawn)
Tests: missing rules file skips, corrupt config degrades to zero, restore
applies the project agent config.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(config): trim per-project config to consumer-backed fields
Drop config that has no live consumer yet, so this PR lands only the
fields actually read at spawn/display:
- Remove prompt rules (agentRules, agentRulesFile, orchestratorRules)
from ProjectConfig. Project/agent instructions belong on the system
prompt path or repo-local AGENTS.md, not another rules family.
- Remove future-only integration config with no consumer: tracker, scm,
scm.webhook, and opencodeIssueSessionStrategy (plus their types,
constants, the github tracker default, CLI flags, and spec schemas).
These return in focused PRs alongside the code that reads them.
Kept: defaultBranch, sessionPrefix, env, symlinks, postCreate,
agentConfig (model/permissions), and worker/orchestrator role
overrides. Cross-agent model/permissions support stays follow-up (#157).
Regenerated openapi.yaml + frontend schema.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(config): reject unknown config JSON keys; confine symlink paths
Two review hardenings on the now-trimmed per-project config surface:
- Project add/set-config endpoints decode with DisallowUnknownFields, so
a misspelled or removed config field surfaces as a clear 400 instead
of being silently dropped. Locks the removals from e213b68 (and any
future trims) at the API gate. Covered by new controllers test.
- applySymlinks now refuses absolute paths and any ".." segment via a
safeRelPath guard, so a project config cannot escape the project or
workspace tree via a malicious symlinks entry. Covered by new
session_manager test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(config): reject symlink path traversal at config write time
greptile flagged ProjectConfig.Symlinks as a write-time path-traversal
gap on PR #154 — the runtime guard in applySymlinks catches a malicious
entry on every spawn, but the config itself accepted it. Move the check
into ProjectConfig.Validate so a bad symlinks entry surfaces as
INVALID_PROJECT_CONFIG when set (CLI/API) instead of silently sitting in
the row until the next spawn. The runtime guard stays as
defense-in-depth.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: harshitsinghbhandari <24b4506@iitb.ac.in>
* Add `ao spawn` and `ao project add`; resolve project repos for worktrees
Make a registered project spawnable end-to-end from the CLI:
- DB-backed RepoResolver: the daemon resolves a project's on-disk repo
path from the projects table (replacing the empty StaticRepoResolver
that failed every lookup), so a session's worktree is cut from the
right repo.
- session_manager defaults an empty spawn branch to ao/<session-id> — a
fresh, unique branch per session, since gitworktree can't reuse a
branch already checked out elsewhere (e.g. main).
- `ao project add --path <repo>`: register a local git repo (POST /api/v1/projects).
- `ao spawn --project <id> [--harness] [--branch] [--prompt] [--issue]`:
spawn a worker session (POST /api/v1/sessions); harness defaults to the
daemon's AO_AGENT.
- Shared postJSON daemon client (reads the run-file for the port, surfaces
the API error envelope).
Stacked on #65, which lands the agent-adapter + session-manager wiring
this depends on.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Address Copilot review on #77
- `ao spawn` no longer prints a branch the sessions API doesn't return
(session metadata is json:"-"), so the output is no longer misleading.
- Unregistered/archived/no-path projects now surface a 400
PROJECT_NOT_RESOLVABLE with an actionable message instead of a generic
500: a new sessionmanager.ErrProjectNotResolvable sentinel the resolver
wraps and writeSessionError maps.
- postJSON reuses the injected Deps.HTTPClient (cloned, with a longer
timeout) instead of a fresh client, keeping HTTP behaviour stubbable.
- postJSON treats a stale run-file (dead PID) as "not running" via
ProcessAlive, matching its docstring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Assert the project-not-resolvable sentinel in the resolver test
Greptile review: harden TestProjectRepoResolver to verify the unregistered
-project error wraps ErrProjectNotResolvable, so a future regression in the
sentinel wrapping (which the HTTP 400 mapping relies on) is caught.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix ao spawn 500 on long session ids (zellij socket-path overflow)
Root cause: the daemon built the zellij runtime with an empty SocketDir,
so zellij fell back to its $TMPDIR-based default (long on macOS). That
left almost none of the ~103-byte unix-socket-path budget for the session
name, so a long session id (e.g. "aoagents-agent-orchestrator-1", derived
from a long project id) was rejected by zellij with "session name must be
less than 0 characters". runtime.Create failed, the spawn 500'd, and the
worktree was rolled back (leaving an orphan ao/ branch).
- New zellij.DefaultSocketDir(): a short, stable per-user socket dir
(/tmp/ao-zellij-<uid>); the daemon uses it (and MkdirAll's it).
- ao spawn's attach hint now prefixes ZELLIJ_SOCKET_DIR so it stays
copy-pasteable against the daemon's socket dir.
- Regression test guards that the socket dir leaves >= 48 bytes for the
session name within the 103-byte limit.
Verified: ao spawn against a long-id project now succeeds (session live,
worktree created) where it previously 500'd.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cli): guard CLI/daemon DTO drift with an e2e round-trip
The CLI keeps its own request structs (spawnRequest, addProjectRequest)
separate from the daemon's canonical DTOs (controllers.SpawnSessionRequest,
project.AddInput). Nothing verified the JSON field names agreed, so a renamed
tag on either side would compile but break at runtime.
Drive `ao spawn` and `ao project add` through the real httpd router and
controllers (fakes only at the service layer) over a real loopback round trip
via postJSON, asserting each field decodes into the right SpawnConfig/AddInput
field. Runs in the normal test lane (no extra ports/processes).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli,daemon): address review findings on ao spawn
- spawn: print the sanitised zellij session name (zellij.SessionName) in the
attach hint; a long/non-conforming session id is registered under a different
name, so the raw id sent users to a missing session.
- client: surface the daemon error envelope's requestId so a failed command can
be correlated with daemon logs.
- daemon: don't swallow the zellij socket-dir MkdirAll error — log it, since a
failure otherwise surfaces later as an opaque socket-bind error on every spawn.
- project: reject an embedded ".." in a project id up front; it passed the id
pattern but yielded an invalid branch (ao/a..b-1) and an opaque 500 at spawn.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: harshitsinghbhandari <24b4506@iitb.ac.in>