Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/agentic-job/config.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,15 @@ test('dispatch passes the run only a configuration from its closed set', () => {
// real agent; where agentic-job is not installed (CI) it is skipped.
const agenticJob = spawnSync('agentic-job', ['--version'], { stdio: 'pipe' }).status === 0;

test('supervisor config preserves hosted sandbox and enables only item watching', { skip: !agenticJob && 'agentic-job is not installed' }, () => {
// A session dispatch of supervisor.yml lets in session.yml's operators and origins.
test('supervisor config preserves hosted sandbox and session, without a listener', { skip: !agenticJob && 'agentic-job is not installed' }, () => {
const check = file => execFileSync('agentic-job', ['config', '--from', path.join(__dirname, file), '--host'], { encoding: 'utf8' });
const supervisor = check('supervisor.toml');
const base = check('hosted.toml');
for (const table of ['sandbox', 'sandbox.check', 'setup']) {
for (const table of ['sandbox', 'sandbox.check', 'setup', 'session']) {
const section = text => text.split(`[${table}]\n`)[1].split('\n[')[0];
assert.equal(section(supervisor), section(base));
}
assert.match(supervisor, /\[session\]\nwatch-item = true/);
assert.doesNotMatch(supervisor, /listen = true/);
});

Expand Down
7 changes: 6 additions & 1 deletion .github/agentic-job/supervisor.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Isolated from ordinary dispatches; like them it watches its item (hosted.toml).
# Match hosted.toml's hosted-runner sandbox and setup, without a tailnet listener.
# Match hosted.toml's hosted-runner sandbox and setup; only a session
# dispatch listens on the tailnet.
[sandbox]
stop-services = [
"docker.socket", "docker.service", "containerd.service",
Expand All @@ -17,7 +18,11 @@ packages = ["python3-venv", "nftables", "just"]
# The caller includes agenthome's profile and skill in the task for every pool.
# Watching stops an operator's later comment being lost while this run works;
# steer treats other authors' text as data, not operator instructions.
# The operators and origins are hosted.toml's, read only by a session
# dispatch (supervisor.yml's `session` input).
[session]
operators = ["cgwalters@gmail.com"]
origins = ["https://cgwalters-forge.github.io"]
watch-item = true

[commit]
Expand Down
77 changes: 69 additions & 8 deletions .github/workflows/supervisor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ on:
description: Public operator request for the supervisor
type: string
default: ''
# The supervisor's own run, kept up for the operator's prompts over the
# tailnet after its first turn, with session.json's idle timeout.
session:
description: Run the agent as an interactive session
type: boolean
default: false
pool:
description: Inference pool from pools.json, for a session only
type: choice
options: [claude, openai, mistral]
default: claude
permissions: {}
# The agent-board commit both jobs build: the executor receives the bot's
# token, so it runs only a revision reviewed here, never agent-board's
Expand All @@ -42,7 +53,11 @@ env:
# group of its own: in a shared group it would evict a pending run and then
# do nothing (#803). That first branch is the negation of health's `if`;
# change both together. A prompt-less dispatch is a health pass too: in
# supervisor-agent it would evict a prompt.
# supervisor-agent it would evict a prompt. A session dispatch, prompt or
# not, is an agent run in supervisor-agent, so health passes keep running
# beside it and none evicts it; it holds that group for up to its idle
# timeout after each turn and its turn limit, while later prompts and
# comments wait (one of them pending) behind it.
concurrency:
group: >-
${{ (!(github.ref == format('refs/heads/{0}', github.event.repository.default_branch) &&
Expand All @@ -51,7 +66,7 @@ concurrency:
github.event.issue.number == fromJSON(vars.SUPERVISOR_REQUESTS_ISSUE || '656') && github.event.issue.locked == true &&
!github.event.issue.pull_request))) && format('supervisor-ignored-{0}', github.run_id)) ||
((github.event.schedule == '*/15 * * * *' ||
(github.event_name == 'workflow_dispatch' && inputs.prompt == '')) && 'supervisor-health') ||
(github.event_name == 'workflow_dispatch' && inputs.prompt == '' && !inputs.session)) && 'supervisor-health') ||
'supervisor-agent' }}
cancel-in-progress: false
jobs:
Expand Down Expand Up @@ -85,9 +100,13 @@ jobs:
repo: ${{ steps.trigger.outputs.repo }}
item: ${{ steps.trigger.outputs.item }}
task: ${{ steps.compose.outputs.task }}
agent: ${{ steps.trigger.outputs.agent }}
model: ${{ steps.trigger.outputs.model }}
budget: ${{ steps.trigger.outputs.budget }}
agent: ${{ steps.session.outputs.agent || steps.trigger.outputs.agent }}
model: ${{ steps.session.outputs.model || steps.trigger.outputs.model }}
budget: ${{ steps.session.outputs.budget || steps.trigger.outputs.budget }}
session: ${{ steps.session.outcome == 'success' }}
idle: ${{ steps.session.outputs.idle }}
session-repo: ${{ steps.session.outputs.repo }}
session-issue: ${{ steps.session.outputs.issue }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down Expand Up @@ -134,12 +153,14 @@ jobs:
run: just build-agent-board "$AGENT_BOARD_REV"
# health is always true; daily marks a request or a due sweep. Without
# read (the lookup failed), only an explicit request sweeps (#814).
# `due` reads only whether a dispatch's prompt is empty, so a session
# with none is passed one: it is an explicit request too.
- name: Whether a pass is due
id: due
timeout-minutes: 1
env:
EVENT: ${{ github.event_name }}
PROMPT: ${{ inputs.prompt }}
PROMPT: ${{ inputs.prompt || (inputs.session && 'session' || '') }}
READ: ${{ steps.previous.outputs.read }}
run: |
"$RUNNER_TEMP/agent-board/bin/agent-board" supervise due --event "$EVENT" --prompt "$PROMPT" \
Expand Down Expand Up @@ -195,10 +216,23 @@ jobs:
# Operator comments also reach an active agent through watch-item; a
# prompt has no other way in, so its operator must see it was dropped.
- name: Fail a manual prompt the trigger declined
if: ${{ github.event_name == 'workflow_dispatch' && inputs.prompt != '' && steps.trigger.outputs.act != 'true' && (vars.SUPERVISOR == 'preview' || vars.SUPERVISOR == 'apply') }}
if: ${{ github.event_name == 'workflow_dispatch' && (inputs.prompt != '' || inputs.session) && steps.trigger.outputs.act != 'true' && (vars.SUPERVISOR == 'preview' || vars.SUPERVISOR == 'apply') }}
run: |
echo "::error::The trigger declined this prompt, most likely because another supervisor agent run is active. Repeat it when that run ends."
echo "::error::The trigger declined this prompt or session, most likely because another supervisor agent run is active. Repeat it when that run ends."
exit 1
# The pool and the publication issue come from trusted files, as
# session.yml's do; the dispatcher picks only a pool name.
- name: Session settings
id: session
if: ${{ github.event_name == 'workflow_dispatch' && inputs.session && steps.trigger.outputs.act == 'true' && (vars.SUPERVISOR == 'preview' || vars.SUPERVISOR == 'apply') }}
timeout-minutes: 1
env:
POOL: ${{ inputs.pool }}
run: |
jq -e --arg repo "$GITHUB_REPOSITORY" '.enabled == true and .repository == $repo' .github/agentic-job/session.json > /dev/null ||
{ echo "::error::session.json does not enable sessions publishing on $GITHUB_REPOSITORY"; exit 1; }
jq -r '"repo=\(.repository)", "issue=\(.issue)", "idle=\(.idleMinutes)"' .github/agentic-job/session.json >> "$GITHUB_OUTPUT"
jq -r --arg pool "$POOL" '(.[$pool] // error("pools.json has no pool \($pool)")) | "agent=\(.agent)", "model=\(.model)", "budget=\(.budget)"' .github/agentic-job/pools.json >> "$GITHUB_OUTPUT"
- name: Supervisor profile and operator request
id: compose
if: ${{ steps.trigger.outputs.act == 'true' && (vars.SUPERVISOR == 'preview' || vars.SUPERVISOR == 'apply') }}
Expand Down Expand Up @@ -235,10 +269,37 @@ jobs:
agent: ${{ needs.health.outputs.agent }}
model: ${{ needs.health.outputs.model }}
budget: ${{ needs.health.outputs.budget }}
session: ${{ needs.health.outputs.session == 'true' }}
idle-minutes: ${{ needs.health.outputs.idle }}
apply-environment: agent-apply
secrets:
SAFE_OUTPUTS_PAT: ${{ secrets.CGWALTERS_BOT_PAT }}

# The wrapper names a session's endpoint on its item, here the requests
# issue; the review app's chat pane looks for it on session.json's issue,
# in a comment by github-actions[bot] with this run's URL, as session.yml's
# wrapper posts it. The address is the wrapper's: its agent job joins the
# tailnet as agentic-job-RUN-ATTEMPT.
session-endpoint:
needs: health
if: ${{ needs.health.outputs.session == 'true' }}
runs-on: ubuntu-26.04
timeout-minutes: 5
permissions:
issues: write
steps:
- name: The session's endpoint on the session issue
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ needs.health.outputs.session-repo }}
ISSUE: ${{ needs.health.outputs.session-issue }}
DOMAIN: ${{ vars.TAILNET_DOMAIN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
[[ -n $DOMAIN ]] || { echo '::error::a session needs the variable TAILNET_DOMAIN'; exit 1; }
endpoint="wss://agentic-job-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT.$DOMAIN/"
gh issue comment "$ISSUE" --repo "$REPO" --body "⏳ [$GITHUB_WORKFLOW]($RUN_URL) is running. Session: after the task's turn the operator attaches at \`$endpoint\`, once the agent job's summary shows it served."

# Runs no agent-produced code: it parses the requests as data.
execute:
needs: agent
Expand Down
57 changes: 56 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,62 @@
set positional-arguments

# Every test of the files under .github.
test: test-config test-supervisor-config test-review-gate test-weekly-material agent-board-pin
test: test-config test-supervisor-config test-supervisor-workflow test-review-gate test-weekly-material agent-board-pin

# supervisor.yml's folded concurrency group and health `if`, evaluated as
# JavaScript on events: a run the gate skips gets a group of its own
# (#803), and a session dispatch, prompt or not, is an agent run.
test-supervisor-workflow:
#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const workflow = fs.readFileSync('.github/workflows/supervisor.yml', 'utf8');
const folded = (key, indent) => new RegExp(`^${' '.repeat(indent)}${key}: >-\\n((?:${' '.repeat(indent + 2)}.*\\n)+)`, 'm')
.exec(workflow)[1].split('\n').map(line => line.trim()).filter(Boolean).join(' ');
const group = /^\$\{\{ (.*) \}\}$/.exec(folded('group', 2))[1];
const gate = folded('if', 4);
assert.ok(group.startsWith(`(!(${gate}) && format('supervisor-ignored-{0}', github.run_id))`));
const { operator } = JSON.parse(fs.readFileSync('.github/poll/agent-board.json', 'utf8'));
const { issue } = JSON.parse(fs.readFileSync('.github/supervisor/config.json', 'utf8')).agent.requests;
const comment = { user: { login: operator } };
const requests = { number: issue, locked: true };
const dispatch = (inputs, ref) => ({ event_name: 'workflow_dispatch', inputs: { prompt: '', session: false, ...inputs }, ref });
for (const [event, expected] of [
[{ event_name: 'issue_comment', event: { comment, issue: requests } }, 'supervisor-agent'],
[{ event_name: 'issue_comment', event: { comment, issue: { ...requests, locked: false } } }, 'supervisor-ignored-42'],
[{ event_name: 'issue_comment', event: { comment, issue: { ...requests, pull_request: { url: 'x' } } } }, 'supervisor-ignored-42'],
[{ event_name: 'issue_comment', event: { comment: { user: { login: 'outsider' } }, issue: requests } }, 'supervisor-ignored-42'],
[dispatch({ prompt: 'do it' }), 'supervisor-agent'],
[dispatch({ prompt: 'do it' }, 'refs/heads/other'), 'supervisor-ignored-42'],
[dispatch({}), 'supervisor-health'],
[dispatch({}, 'refs/heads/other'), 'supervisor-ignored-42'],
[dispatch({ session: true }), 'supervisor-agent'],
[dispatch({ session: true, prompt: 'look at #1' }), 'supervisor-agent'],
[dispatch({ session: true }, 'refs/heads/other'), 'supervisor-ignored-42'],
[{ event_name: 'schedule', event: { schedule: '*/15 * * * *' } }, 'supervisor-health'],
[{ event_name: 'schedule', event: { schedule: '7 */2 * * *' } }, 'supervisor-agent'],
]) {
const context = {
format: (f, ...a) => f.replace(/\{(\d+)\}/g, (_, i) => a[i]),
fromJSON: JSON.parse,
vars: {},
inputs: event.inputs ?? {},
github: {
event_name: event.event_name, run_id: 42, ref: event.ref ?? 'refs/heads/main',
event: { repository: { default_branch: 'main' }, ...event.event },
},
};
const got = vm.runInNewContext(group, context);
assert.equal(got, expected, JSON.stringify(event));
assert.equal(vm.runInNewContext(gate, context), !got.startsWith('supervisor-ignored-'), JSON.stringify(event));
}
// A session is an explicit request to `due`, and reaches the wrapper
// with session.json's idle timeout.
assert.match(workflow, /^ {10}PROMPT: \$\{\{ inputs\.prompt \|\| \(inputs\.session && 'session' \|\| ''\) \}\}$/m);
assert.match(workflow, /^ {6}session: \$\{\{ needs\.health\.outputs\.session == 'true' \}\}\n {6}idle-minutes: \$\{\{ needs\.health\.outputs\.idle \}\}$/m);
assert.match(workflow, /"idle=\\\(\.idleMinutes\)"' \.github\/agentic-job\/session\.json/);
console.log('ok supervisor concurrency groups and health gate, sessions included');

# Exercise the actual release selection and body filters without API calls.
test-weekly-material:
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,17 @@ gh workflow run supervisor.yml -R cgwalters-forge/tracker \
-f prompt="close what triage found done in agent-board"
```

With `-f session=true` (and optionally `-f pool=openai`, default `claude`)
the agent runs as an interactive session: after its first turn it waits for
the operator's prompts over the tailnet, for up to `session.json`'s
`idleMinutes` each, and its `supervisor-actions` comment goes to the executor
when the session ends, as for any run. A session is an explicit request, in
`supervisor-agent`, and needs `TAILNET_DOMAIN` and `session.json` enabled.
A step posts its `wss://` endpoint on `session.json`'s issue (#728) as
`session.yml`'s runs have it; the review site's chat pane reads only the
workflows its configuration lists, so far `session.yml`. `[session]`
operators and origins are `hosted.toml`'s, copied into `supervisor.toml`.

A check that cannot clear fails every pass and so started an agent every
cooldown hour to read the same report (#804). With
`agent.skipUnchangedFailures` set to true (it ships false),
Expand Down