Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/reusable-code.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# Reusable code agent workflow. Called by thin callers in .fullsend repos
# via workflow_call. Runs in the caller's repo context (secrets, checkout).
# Reusable code agent workflow. Called by dispatch workflows via workflow_call.
# Runs in the caller's repo context (secrets, checkout).
name: Code Agent

concurrency:
group: fullsend-code-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }}
Comment thread
ifireball marked this conversation as resolved.
cancel-in-progress: true

on:
workflow_call:
inputs:
Expand Down
22 changes: 19 additions & 3 deletions .github/workflows/reusable-dispatch.yml
Comment thread
ifireball marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Reusable dispatch workflow for per-repo installation mode.
# Routes events to the appropriate stage reusable workflow via conditional
# workflow_call jobs. This is the per-repo equivalent of the per-org
# dispatch.yml + thin caller pair.
# workflow_call jobs. Same direct-dispatch pattern as per-org dispatch.yml
# (ADR 0041).
#
# Flow: shim (per-repo) → reusable-dispatch.yml → reusable-{stage}.yml
# Nesting: 3 levels of workflow_call (within GitHub's 4-level limit)
Expand Down Expand Up @@ -49,7 +49,7 @@ jobs:
contents: read
pull-requests: read
outputs:
stage: ${{ steps.role-check.outputs.skipped == 'true' && '' || steps.route.outputs.stage }}
stage: ${{ steps.role-check.outputs.skipped != 'true' && steps.route.outputs.stage || '' }}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[info] correctness

Good bug fix: the old stage output expression always evaluated to stage regardless of skip status. The corrected expression properly returns empty string when the role is skipped.

trigger_source: ${{ steps.route.outputs.trigger_source }}
event_payload: ${{ steps.payload.outputs.event_payload }}
steps:
Expand Down Expand Up @@ -413,3 +413,19 @@ jobs:
FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}

FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}

prioritize:
name: Prioritize
needs: route
if: needs.route.outputs.stage == 'prioritize'
uses: ./.fullsend/.github/workflows/prioritize.yml
permissions:
contents: read
id-token: write
with:
event_type: ${{ github.event_name }}
source_repo: ${{ github.repository }}
event_payload: ${{ needs.route.outputs.event_payload }}
secrets:
FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}
FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
16 changes: 14 additions & 2 deletions .github/workflows/reusable-fix.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
# Reusable fix agent workflow. Called by thin callers in .fullsend repos
# via workflow_call. Runs in the caller's repo context (secrets, checkout).
# Reusable fix agent workflow. Called by dispatch workflows via workflow_call.
# Runs in the caller's repo context (secrets, checkout).
name: Fix Agent

# Single concurrency group per PR. A human /fix cancels any running fix so the
# human's instruction takes immediate effect. Bot-triggered runs also cancel
# previous bot runs on the same PR.
concurrency:
group: >-
fullsend-fix-${{ inputs.source_repo }}-${{
fromJSON(inputs.event_payload).pull_request.number
Comment thread
ifireball marked this conversation as resolved.
|| fromJSON(inputs.event_payload).issue.number
|| inputs.pr_number
}}
cancel-in-progress: true

on:
workflow_call:
inputs:
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/reusable-retro.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# Reusable retro agent workflow. Called by thin callers in .fullsend repos
# via workflow_call. Runs in the caller's repo context (secrets, checkout).
# Reusable retro agent workflow. Called by dispatch workflows via workflow_call.
# Runs in the caller's repo context (secrets, checkout).
name: Retro Agent

concurrency:
group: fullsend-retro-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }}
cancel-in-progress: true

on:
workflow_call:
inputs:
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/reusable-review.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# Reusable review agent workflow. Called by thin callers in .fullsend repos
# via workflow_call. Runs in the caller's repo context (secrets, checkout).
# Reusable review agent workflow. Called by dispatch workflows via workflow_call.
# Runs in the caller's repo context (secrets, checkout).
name: Review Agent

concurrency:
group: fullsend-review-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }}
cancel-in-progress: true

on:
workflow_call:
inputs:
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/reusable-triage.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# Reusable triage agent workflow. Called by thin callers in .fullsend repos
# via workflow_call. Runs in the caller's repo context (secrets, checkout).
# Reusable triage agent workflow. Called by dispatch workflows via workflow_call.
# Runs in the caller's repo context (secrets, checkout).
name: Triage Agent

concurrency:
group: fullsend-triage-${{ inputs.source_repo }}-${{ fromJSON(inputs.event_payload).issue.number }}
cancel-in-progress: true

on:
workflow_call:
inputs:
Expand Down
4 changes: 4 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ repos:
- property "workflow_repository" is not defined
- -ignore
- SC2016
- -ignore
- 'could not read reusable workflow file for "\./\.github/workflows/prioritize\.yml"'
- -ignore
- 'could not read reusable workflow file for "\./\.fullsend/\.github/workflows/prioritize\.yml"'

- repo: local
hooks:
Expand Down
19 changes: 6 additions & 13 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,11 @@ Infrastructure platform choice and configuration are specified in the adopting o

- Forge abstraction: all forge operations go through the `forge.Client` interface, keeping the rest of the codebase forge-agnostic ([ADR 0005](ADRs/0005-forge-abstraction-layer.md)).
- Installation model: ordered layer stack (install forward, uninstall reverse, analyze for status reporting) with idempotent operations. Current stack: config-repo → workflows → secrets → inference → dispatch → enrollment ([ADR 0006](ADRs/0006-ordered-layer-model.md)).
- Cross-repo dispatch: enrolled repos call `.fullsend` via `workflow_call`; a dispatch workflow mints OIDC tokens exchanged at a central token mint (GCP Cloud Function) for scoped GitHub App installation tokens per agent role. App PEM secrets are stored in Secret Manager, not the config repo ([ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md)).
- Cross-repo dispatch: enrolled repos call `.fullsend` via `workflow_call`; dispatch routes events to upstream reusable workflows via synchronous `workflow_call` jobs. Each reusable workflow mints OIDC tokens exchanged at a central token mint (GCP Cloud Function) for scoped GitHub App installation tokens per agent role. App PEM secrets are stored in Secret Manager, not the config repo ([ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md), [ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)).
- Shim workflow security: `pull_request_target` prevents PR authors from modifying the shim workflow. No long-lived secrets flow through the shim — OIDC tokens are issued by the GitHub runtime and scoped to the workflow run ([ADR 0009](ADRs/0009-pull-request-target-in-shim-workflows.md)).
- Repo maintenance: a workflow in `.fullsend` (`.github/workflows/repo-maintenance.yml`) reconciles enrollment shims in target repos when `config.yaml` changes or on manual dispatch. The CLI's `EnrollmentLayer.Install()` dispatches this workflow via `workflow_dispatch` and monitors it for completion, then reports any enrollment PRs created in target repos.
- Installer scaffold: the `WorkflowsLayer` deploys content from an embedded scaffold (`internal/scaffold/`), keeping deployable files as real files under version control rather than Go string constants.
- Reusable workflows: agent workflows in `.fullsend` are thin callers (~40-70 lines) that delegate infrastructure logic to upstream reusable workflows (`fullsend-ai/fullsend/.github/workflows/reusable-*.yml`) via `workflow_call`. Infrastructure patches ship once upstream and propagate to all orgs without re-install ([ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md)).
- Event-driven stage dispatch: eliminate `workflow_dispatch` + `gh workflow run` fan-out from `dispatch.yml` in favor of synchronous `workflow_call` so the dispatched run stays linked to the caller ([ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)).
- Reusable workflows: `dispatch.yml` in `.fullsend` calls upstream reusable workflows (`fullsend-ai/fullsend/.github/workflows/reusable-*.yml`) directly via `workflow_call` jobs (no per-stage thin callers). Infrastructure patches ship once upstream and propagate to all orgs without re-install ([ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md), [ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)).

**Open questions:**

Expand Down Expand Up @@ -131,10 +130,6 @@ The mechanism that assigns work to agents and prevents conflicts. Responsible fo

The existing design principle is that [the repo is the coordinator](problems/agent-architecture.md#interaction-model-the-repo-as-coordinator) — branch protection, CODEOWNERS, status checks, and GitHub events provide coordination without a central orchestrator. The agent dispatch and coordination layer may be nothing more than the glue that connects GitHub webhooks to agent infrastructure. Or it may need to be more.

**Decided:**

- Event-driven stage dispatch runs synchronously via `workflow_call` to preserve run correlation in the GitHub Actions UI (see [ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)).

**Open questions:**

- Is GitHub's event system sufficient, or do we need additional coordination logic (e.g. to prevent two code agents from picking up the same issue)?
Expand Down Expand Up @@ -180,7 +175,6 @@ Observability is a cross-cutting concern that touches every other component. Eac
**Decided:**

- JSONL reasoning trace exposure: raw JSONL conversation transcripts are extracted from sandboxes and stored with owner-scoped access. Credential scanning acts as an invariant check on [ADR 0017](ADRs/0017-credential-isolation-for-sandboxed-agents.md)'s isolation model. Agents handling data from protected sources beyond the target repo can opt in to JSONL suppression via configuration ([ADR 0021](ADRs/0021-jsonl-reasoning-trace-exposure.md)).
- Event-driven stage dispatch remains traceable end-to-end in the GitHub Actions UI by using synchronous `workflow_call` dispatch (see [ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md)).

**Open questions:**

Expand Down Expand Up @@ -518,14 +512,13 @@ GitHub event ──► SHIM WORKFLOW (fullsend.yml in enrolled repo)
╔═══════════════════════════════════════════════════════════════╗
║ DISPATCH WORKFLOW (.fullsend repo, dispatch.yml) ║
║ ║
║ Mints OIDC token → Cloud Function (token mint) → scoped ║
║ GitHub App installation token per agent role. ║
║ Dispatches per-role agent workflows (code.yml, triage.yml). ║
║ Routes to stage; synchronous workflow_call to upstream ║
║ reusable-{stage}.yml (or prioritize.yml in .fullsend). ║
╚═══════════════════════════════════════════════════════════════╝
╔═══════════════════════════════════════════════════════════════╗
║ AGENT WORKFLOW (.fullsend repo, e.g. code.yml)
║ AGENT WORKFLOW (fullsend-ai/fullsend reusable-*.yml) ║
║ ║
║ Validates source repo is enrolled in config.yaml. ║
║ Uses scoped GitHub App tokens: ║
Expand Down Expand Up @@ -607,7 +600,7 @@ GitHub event ──► SHIM WORKFLOW (fullsend.yml in enrolled repo)

| Abstract layer | MVP technology | ADR |
|---|---|---|
| Dispatcher | Shim workflow (`fullsend.yml`) in enrolled repo → `workflow_call` to `.fullsend/dispatch.yml` → OIDC mint → per-role agent workflows (thin callers → upstream reusable workflows) | [ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md), [ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md) |
| Dispatcher | Shim workflow (`fullsend.yml`) in enrolled repo → `workflow_call` to `.fullsend/dispatch.yml` → synchronous `workflow_call` to upstream reusable workflows | [ADR 0008](ADRs/0008-workflow-dispatch-for-cross-repo-dispatch.md), [ADR 0031](ADRs/0031-reusable-workflows-for-action-installed-distribution.md), [ADR 0041](ADRs/0041-synchronous-workflow-call-event-dispatch.md) |
| Agent runner | GitHub Actions job → `fullsend run` CLI (via `fullsend-ai/fullsend@v0` composite action) | |
| Harness store | YAML files in `.fullsend/harness/` (e.g. `code.yaml`, `triage.yaml`) | |
| Sandbox | OpenShell with per-agent L7 network policies (endpoint + binary restrictions) | |
Expand Down
52 changes: 25 additions & 27 deletions e2e/admin/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,6 @@ func TestAdminInstallUninstall(t *testing.T) {
// scripts/, env/) and upstream-only dirs (.github/actions/, .github/scripts/) are
// provided at runtime via sparse checkout in reusable workflows.
for _, path := range []string{
".github/workflows/triage.yml",
".github/workflows/code.yml",
".github/workflows/review.yml",
".github/workflows/fix.yml",
".github/workflows/dispatch.yml",
".github/workflows/repo-maintenance.yml",
".github/workflows/prioritize.yml",
Expand Down Expand Up @@ -254,6 +250,11 @@ func runTriageDispatchSmokeTest(t *testing.T, env *e2eEnv) {
t.Helper()
ctx := context.Background()

// Capture a lower-bound timestamp *before* creating the issue.
// The shim/dispatch workflows can start very quickly, and we only want to
// filter out runs from earlier test phases.
issueCreatedAt := time.Now()

// File a test issue to trigger the shim workflow.
issueTitle := fmt.Sprintf("e2e-triage-test-%s", env.runID)
issueBody := `## Bug Report
Expand Down Expand Up @@ -295,17 +296,14 @@ Files over 64KB save fine if they contain only ASCII characters.`
}
})

// Wait for the triage workflow to be dispatched in .fullsend.
// The shim fires on issues:opened and dispatches to triage.yml.
// The shim typically fires within ~5s of the issue being created,
// so 12 attempts at 5s intervals (60s total) is generous.
// Filter by CreatedAt to avoid false positives from previous runs.
issueCreatedAt := time.Now()
t.Log("Waiting for triage workflow to be dispatched...")
var triageRun *forge.WorkflowRun
// Wait for the enrolled-repo shim (fullsend.yaml). Cross-repo workflow_call
// runs appear on the caller repo, not as separate dispatch.yml runs in .fullsend.
// Chain: fullsend.yaml → .fullsend/dispatch.yml → reusable-triage (sync).
t.Log("Waiting for fullsend shim workflow to run...")
var shimRun *forge.WorkflowRun
for attempt := 0; attempt < 12; attempt++ {
time.Sleep(5 * time.Second)
runs, listErr := env.client.ListWorkflowRuns(ctx, env.org, forge.ConfigRepoName, "triage.yml")
runs, listErr := env.client.ListWorkflowRuns(ctx, env.org, testRepo, "fullsend.yaml")
if listErr != nil {
t.Logf("Attempt %d: error listing workflow runs: %v", attempt+1, listErr)
continue
Expand All @@ -322,24 +320,24 @@ Files over 64KB save fine if they contain only ASCII characters.`
}
t.Logf("Attempt %d: found run %d (status: %s, conclusion: %s, created: %s)", attempt+1, run.ID, run.Status, run.Conclusion, run.CreatedAt)
r := run // avoid loop variable capture
triageRun = &r
shimRun = &r
break
}
if triageRun != nil {
if shimRun != nil {
break
}
t.Logf("Attempt %d: no triage workflow runs found yet", attempt+1)
t.Logf("Attempt %d: no fullsend shim workflow runs found yet", attempt+1)
}
require.NotNil(t, triageRun, "triage workflow should have been dispatched in .fullsend repo")
require.NotNil(t, shimRun, "fullsend shim workflow should have run in enrolled repo")

// Wait for the workflow run to complete (up to 12 minutes: 10-minute agent
// timeout + sandbox setup overhead).
t.Logf("Waiting for triage workflow run %d to complete...", triageRun.ID)
t.Logf("Waiting for fullsend shim workflow run %d to complete...", shimRun.ID)
var finalRun *forge.WorkflowRun
deadline := time.Now().Add(12 * time.Minute)
for time.Now().Before(deadline) {
time.Sleep(15 * time.Second)
run, getErr := env.client.GetWorkflowRun(ctx, env.org, forge.ConfigRepoName, triageRun.ID)
run, getErr := env.client.GetWorkflowRun(ctx, env.org, testRepo, shimRun.ID)
if getErr != nil {
t.Logf("Error polling workflow run: %v", getErr)
continue
Expand All @@ -350,34 +348,34 @@ Files over 64KB save fine if they contain only ASCII characters.`
break
}
}
require.NotNil(t, finalRun, "triage workflow run should have completed within deadline")
require.NotNil(t, finalRun, "fullsend shim workflow run should have completed within deadline")

// If the run failed, save logs and artifacts for debugging.
if finalRun.Conclusion != "success" {
runURL := fmt.Sprintf("https://github.com/%s/%s/actions/runs/%d", env.org, forge.ConfigRepoName, finalRun.ID)
fmt.Fprintf(os.Stderr, "::notice::Triage workflow run %d failed (conclusion: %s). Downloading debug artifacts. Run URL: %s\n", finalRun.ID, finalRun.Conclusion, runURL)
runURL := fmt.Sprintf("https://github.com/%s/%s/actions/runs/%d", env.org, testRepo, finalRun.ID)
fmt.Fprintf(os.Stderr, "::notice::Fullsend shim run %d failed (conclusion: %s). Downloading debug artifacts. Run URL: %s\n", finalRun.ID, finalRun.Conclusion, runURL)

debugDir := filepath.Join(env.screenshotDir, fmt.Sprintf("triage-run-%d", finalRun.ID))
debugDir := filepath.Join(env.screenshotDir, fmt.Sprintf("fullsend-run-%d", finalRun.ID))
_ = os.MkdirAll(debugDir, 0o755)

// Save workflow logs.
logs, logErr := env.client.GetWorkflowRunLogs(ctx, env.org, forge.ConfigRepoName, finalRun.ID)
logs, logErr := env.client.GetWorkflowRunLogs(ctx, env.org, testRepo, finalRun.ID)
if logErr != nil {
t.Logf("Could not fetch run logs: %v", logErr)
} else {
logPath := filepath.Join(debugDir, "workflow-logs.txt")
if writeErr := os.WriteFile(logPath, []byte(logs), 0o644); writeErr != nil {
t.Logf("Could not write logs to %s: %v", logPath, writeErr)
} else {
fmt.Fprintf(os.Stderr, "::notice file=%s::Triage run %d workflow logs saved\n", logPath, finalRun.ID)
fmt.Fprintf(os.Stderr, "::notice file=%s::Fullsend shim run %d workflow logs saved\n", logPath, finalRun.ID)
}
t.Logf("Workflow run logs:\n%s", logs)
}

// Download run artifacts (transcripts, etc).
downloadRunArtifacts(ctx, env.token, env.org, forge.ConfigRepoName, finalRun.ID, debugDir, t)
downloadRunArtifacts(ctx, env.token, env.org, testRepo, finalRun.ID, debugDir, t)

t.Fatalf("Triage workflow run %d concluded with %q, expected success. Debug artifacts saved to %s", finalRun.ID, finalRun.Conclusion, debugDir)
t.Fatalf("Fullsend shim workflow run %d concluded with %q, expected success. Debug artifacts saved to %s", finalRun.ID, finalRun.Conclusion, debugDir)
}

// Verify the triage agent posted a comment on the issue.
Expand Down
6 changes: 3 additions & 3 deletions internal/layers/workflows.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ func init() {
}

// WorkflowsLayer manages workflow files and CODEOWNERS in the .fullsend
// config repo. It writes the thin caller workflows, composite actions,
// and a CODEOWNERS file that grants the installing user ownership of all
// config-repo contents.
// config repo. It writes dispatch.yml, prioritize.yml, repo-maintenance.yml,
// composite actions, and a CODEOWNERS file that grants the installing user
// ownership of all config-repo contents.
type WorkflowsLayer struct {
org string
client forge.Client
Expand Down
Loading
Loading