Repository navigation
[feat] session-to-task: turn a session's bug fix into a benchmark task #1402
Description
Example
person: turn this fix into a benchmark task
agent: teamai workflow start --describe session-to-task --driver skill
proposes {"session":"7c1e…","source":"claude-code","suite":"benchmarks/frontend",
"start":"3f2c9a7d1e0b4c6a8f5d2e1b0c9a8f7e6d5c4b3a","fix":"9a1d4e7c2b8f0a3d6e5c1b9f8a7d2e4c6b0a1f3e"}
person: confirms, picking "Same, but ask me at optional approvals"
agent: works the draft step with the person, then asks the approval
"Propose benchmarks/frontend/tasks/pod-startup.yaml to the team as a pull request?"
person: yes
step: teamai push --file /tmp/pod-startup.yaml --path benchmarks/frontend/tasks/pod-startup.yaml
✔ Pull Request created: https://github.com/acme/team/pull/77
A member fixed a bug with their agent and keeps it as a benchmark task. The session that fixed the bug drafts the task with them, so no transcript has to be read back. For a suite in a namespace, suite is its directory, such as benchmarks/web/frontend, and the task goes to benchmarks/web/frontend/tasks/pod-startup.yaml.
The task file it proposes, in #1180's format:
# benchmarks/frontend/tasks/pod-startup.yaml
schemaVersion: v1alpha1
title: Pods time out on first start
prompt: |
The first start of a pod times out after 30 s on a cold node. Make it start.
startingCommit: 3f2c9a7d1e0b4c6a8f5d2e1b0c9a8f7e6d5c4b3a
check:
run: npx vitest run test/startup.test.ts
protect: ['test/**', 'vitest.config.ts', 'package.json', 'package-lock.json']
add: ['test/startup.test.ts'] # files under protect the fix added or changed: read from source.fix, never in the agent's clone
source: { platform: claude-code, sessionId: 7c1e…, fix: 9a1d4e7c2b8f0a3d6e5c1b9f8a7d2e4c6b0a1f3e }The fix brought its own test. A trial's agent must not see it, or it could fit its change to the expected result instead of fixing the bug. check.add (#1180) keeps it out of the agent's clone and writes it in just before the check:
step test/startup.test.ts
trial: clone at 3f2c9a7 absent: the clone holds only 3f2c9a7 and its history, so not even git has the fix
trial: check protect restored, then written from source.fix → measures the agent's change against the hidden test
--try at startingCommit written from source.fix → must fail: with the test present, that is its answer about the bug
--try at source.fix protect restored, then written from source.fix → must pass
The team repo's CI tries the task's check on the pull request with #1180's --try:
# .github/workflows/benchmark-tasks.yml in the team repo
on:
pull_request:
paths: ['benchmarks/frontend/tasks/*.yaml']
jobs:
try:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5 # the team repo, at the pull request
with: { path: team, fetch-depth: 0 }
- uses: actions/checkout@v5 # the suite's repo, with every commit a task names
with: { repository: acme/frontend, path: project, fetch-depth: 0, token: "${{ secrets.PROJECT_READ_TOKEN }}" }
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm install -g teamai-cli
- working-directory: project
shell: bash
run: |
git -C ../team diff --name-only --diff-filter=AM "origin/${{ github.base_ref }}...HEAD" -- 'benchmarks/frontend/tasks/*.yaml' |
while read -r file; do
suite=$(basename "$(dirname "$(dirname "$file")")")
teamai workflow start "$suite" --benchmark --try "../team/$file" --driver local --sandbox
donetask at startingCommit at source.fix (protect restored) job
pod-startup check fails ✔ check passes ✔ passes: its test is hidden in check.add and written in before each check
old-bug check passes ✖ not tried fails: "check passes without changes at 3f2c9a7"
bad-fix check fails ✔ check fails ✖ fails: "check fails at the fix 9a1d4e7"
not-added check fails ✔ check fails ✖ fails: "check fails at the fix 9a1d4e7": the fix's test is not in check.add, so neither commit has it
add-missing not tried not tried fails: "test/startup.test.ts is not a file at the fix 9a1d4e7"
no-fix check fails ✔ not tried passes, and says the check was not tried on a fix
criteria-only no check no check passes, and says the task has no check to try
missing-tool could not run not tried fails, naming the command and 3f2c9a7
setup-error setup failed not tried fails, naming setup and 3f2c9a7
With a check that runs the whole suite, not-added fails earlier: "check passes without changes at 3f2c9a7". --try sees only the check's exit, not why it failed.
The job covers one project: its repository and path filter name the same suite repo. A team with suites for several repositories adds a job per project with matching filters. PROJECT_READ_TOKEN reads that project. The team marks the job required in its merge rules, so a task that fails it never reaches the suite. The --try messages are #1180's.
Behavior
The default:
schemaVersion: v1alpha1
name: session-to-task
description: Turn the bug this session fixed into a benchmark task, proposed to the team as a pull request. Use when the person asks to keep this fix as a benchmark task or add it to a benchmark suite.
invoke: ask
inputs: { session: string, source: string, suite: string, start: string, fix: string }
steps:
- id: draft
interactive: true
prompt: |
Turn the bug this {{ inputs.source }} session {{ inputs.session }} fixed into one task of the benchmark suite in {{ inputs.suite }}.
Work it with the person. The task starts at commit {{ inputs.start }}; the fix is commit {{ inputs.fix }} (empty when there is none).
Write the task file outside the repository, in the format of the files under {{ inputs.suite }}/tasks/:
schemaVersion v1alpha1; title; prompt, the bug as the person first described it, without the fix;
startingCommit {{ inputs.start }}; exactly one of check or successCriteria;
and source with platform {{ inputs.source }}, sessionId {{ inputs.session }} and, when there is a fix, fix {{ inputs.fix }}.
check has run, and protect listing every test, config, script and manifest the check reads.
When there is a fix, check also has add: every file under protect that the fix added or changed and the check reads
(tests, fixtures, helpers; git diff --name-only {{ inputs.start }} {{ inputs.fix }} lists the candidates), one path each, no globs.
The trial's agent never sees the add files, so it cannot fit its change to them.
Every add path must be a file at commit {{ inputs.fix }}; if one is not, tell the person to commit it and start session-to-task again, and return skip.
When an add file calls a function, file, option or export that commit {{ inputs.start }} lacks, name it and its signature in prompt,
so the task can be solved without seeing the test.
If the person decides the session is not a good task, return {"verdict":"skip","file":"","path":""}.
Otherwise return verdict draft, the file's path, and {{ inputs.suite }}/tasks/<task id>.yaml as path,
where the task id is letters, digits, "-" or "_".
output: { verdict: draft | skip, file: string, path: string }
- id: propose
needs: [draft]
if: steps.draft.outputs.verdict == 'draft'
approve: Propose {{ steps.draft.outputs.path }} to the team as a pull request?
command: teamai push --file "$FILE" --path "$TASK_PATH"
env: { FILE: "{{ steps.draft.outputs.file }}", TASK_PATH: "{{ steps.draft.outputs.path }}" }session-to-task is a teamai default workflow, served and overridden like session-close (#1388): a team's workflows/session-to-task.yaml replaces it. Nothing offers it automatically. A person asks their agent for it, or the generated workflow-session-to-task skill points the agent at it.
| Step | Kind | Why |
|---|---|---|
draft |
interactive prompt, worked by the session that fixed the bug, with the person | That session holds what the person asked and how it was fixed. A child agent would need the transcript, and no command prints one |
propose |
command, optional approve, only on verdict: draft |
The task reaches the team as a pull request, like any team asset teamai push proposes. A wrong task costs a review, not a broken suite. The gate lives in the definition, so the engine records its answer and the dashboard shows it. Under the recommended start the agent answers it, recorded as the agent's; "ask me at optional approvals" makes it the person's. required would send the person to their own terminal or the dashboard (agent_in_path refuses an answer from the session), a cost kept for what the team does not review, like session-close's transcript publish (#1388) |
What describe proposes, as a value and a why on each entry of describe's inputs (#1388's seam):
| Input | Proposed value | No value when |
|---|---|---|
session, source |
the caller's agent session, as #1388 proposes them | no agent session variable: then no input gets a value |
start |
gitAtStart.head of the calling session's earliest dashboard session_start event |
no such event, that earliest event has no gitAtStart, or its gitAtStart.dirty is true |
fix |
head of getHeadState(cwd), when it differs from the proposed start and dirty is false |
start has no value, head equals start, tracked files are modified, or no git. Untracked files do not count, so draft checks that every add path is a file at fix |
suite |
the directory of the one resolved cached suite whose repo matches this checkout's origin (remotesMatch), such as benchmarks/frontend or benchmarks/web/frontend |
none or several match, or no origin |
start fix (HEAD) proposal
3f2c9a7 9a1d4e7 both, as is
3f2c9a7 3f2c9a7 start only: HEAD is still the starting commit
3f2c9a7 9a1d4e7 * start only: tracked files modified; commit the fix, then describe again
(none) 9a1d4e7 neither: the person gives both
A proposal is always a full 40-character commit. Each of these inputs carries a why, also when it has no value, so the person sees where a commit came from or why it is missing:
{"name":"start","type":{"type":"string"},"value":"3f2c9a7d…","why":"The commit this session started at."},
{"name":"fix","type":{"type":"string"},"why":"No value: tracked files are modified. Commit the fix, then ask again, or give the commit."},
{"name":"suite","type":{"type":"string"},"value":"benchmarks/frontend","why":"The one benchmark suite whose repo is this checkout's origin."}session and source carry a why like #1388's, worded for this workflow: "This session: the one that fixed the bug." The other fix and suite reasons follow the "No value when" column: HEAD is still start; no starting commit; no suite, or several, match origin (naming them).
teamai push --file <local file> --path <team path>:
$ teamai push --file /tmp/pod-startup.yaml --path benchmarks/frontend/tasks/pod-startup.yaml
✔ Pull Request created: https://github.com/acme/team/pull/77
$ teamai push --file /tmp/pod-startup.yaml --path benchmarks/frontend/pod-startup.yaml
✖ benchmarks/frontend/pod-startup.yaml is not a benchmark task path. A task goes in benchmarks/<suite>/tasks/<task>.yaml or benchmarks/<namespace>/<suite>/tasks/<task>.yaml.
$ teamai push --file /tmp/pod-startup.yaml --path benchmarks/missing/tasks/pod-startup.yaml
✖ benchmarks/missing/suite.yaml does not exist, so benchmarks/missing/tasks/pod-startup.yaml belongs to no suite. Pick a suite from teamai list benchmarks.
$ teamai push --file /tmp/pod-startup.yaml --path benchmarks/frontend/tasks/pod-startup.yaml --role web
✖ --path names the destination, so it cannot be combined with --skill, --role, --project or --shared.| Flags | Result |
|---|---|
--file and --path |
pushes exactly that file to that path, without asking, and opens or updates the pull request as push does |
only one of --file and --path |
refused: they go together |
with --skill, --role, --project or --shared |
refused, as above |
with --all |
--all changes nothing |
with --branch |
pushes to that branch, as today |
| same bytes already at that path | "no changes", as push answers today |
| Case | Result |
|---|---|
| The person declines the task | draft succeeds with verdict: skip; propose is skipped and asks nothing; no pull request |
| The approval is answered no | propose is skipped; no pull request |
| The person picked the recommended start | the agent answers the approval and says why, recorded as the agent's answer. Pick "ask me at optional approvals" to answer it yourself |
| The person picked "Same, but answer optional approvals automatically" | the engine answers the approval yes (auto_approve) and the pull request opens |
An add file is not committed in fix |
draft tells the person to commit it and start again, and returns verdict: skip |
start or fix is not on the project's remote |
The pull request opens; the CI job fails naming the missing commit. Push the fix's branch, or set source.fix to the commit that landed the fix on the default branch, and re-run the job |
| Unattended describe | denied: invoke: ask and an interactive step need a person (#1304) |
A team override of session-to-task with other inputs |
describe proposes values only for inputs whose name and type match the table above (#1388's rule) |
Acceptance criteria
- The default parses with
parseWorkflow, withinvoke: ask,interactive: trueondraft, the output{ verdict: draft | skip, file, path }, andproposeguarded byif: steps.draft.outputs.verdict == 'draft'with an optionalapprove. It is served behindloadCachedWorkflowandlistCachedWorkflowslikesession-close([feat] Default workflows a team can override, starting with session-close #1388), and a team fileworkflows/session-to-task.yamlreplaces it. - A run whose
draftreturns{"verdict":"skip","file":"","path":""}succeeds, asks no approval and opens no pull request. Covered by a test. - describe proposes
start,fixandsuitewith the rules above, each with awhy, also when it proposes no value. Covered by tests: an earliestsession_startwithgitAtStart; one whosegitAtStart.dirtyis true (nostart); an earliest one without it and a later resume with it (nostart);HEADequal tostart; a dirty checkout; one matching suite; two matching suites; an SSHoriginmatching an HTTPSrepo; a namespaced suite proposed asbenchmarks/web/frontend; no agent session (no values). teamai push --file --pathputs only the destination in the pull request. Covered by a test whose team clone also has a modifiedteamai.yamland another changed resource: the pull request's tree differs from its base only at the destination. Askills/destination adds or rewrites no other skill file. In self mode the pull request holds.teamai/<path>, and the active checkout's files, index andHEADare unchanged.- The error rows of the two
pushtables are covered by tests, including a structurally wrong task path and a missingsuite.yamlas separate cases. --pathtakes a path under any placeable type's root (skills/,scorers/,benchmarks/, …), validated against the namespacespushresolves today and never relocated. A path that escapes the team root, or goes through a symlink, is refused. Covered by ascorers/test and a../test.- The guide's CI job, run on a fixture team repo pull request with no
teamai init, passes for a task whose check fails atstartingCommitand passes at its fix; passes for a task whose test the fix added andcheck.addlists; passes for a task whose fix added a case to an existing test file thatcheck.addlists; fails for the first task withoutcheck.add; fails naming the path for anaddpath that is not a file at the fix; and fails with [feat] Run benchmark suites as workflows #1180's message for one whose check passes atstartingCommit.
Definition of done
docs/guide/benchmarks.mdanddocs/guide/zh-CN/benchmarks.md: a section "Turn a session into a task" with the session example, the task file, the CI job and its outcome table.docs/guide/workflow.mdand its zh-CN page:session-to-taskin the list of default workflows ([feat] Default workflows a team can override, starting with session-close #1388), with its inputs.docs/guide/sharing.mdand its zh-CN page:push --file --path, with the flag table.pushhelp text for--fileand--path;skill-data/core/references/commands.mdregenerated withnpx vitest run commands-reference -u.CHANGELOG.mdunder## [Unreleased]›### Added: thesession-to-taskdefault workflow andteamai push --file --path.- Real-CLI record: one attended session that runs
session-to-taskto a pull request on a fixture team repo, and one run of the CI job's--trycommand on that pull request's branch.
Out of scope
- Trying the check before the pull request opens, on the member's machine. The check runs project setup and code, so it runs in the team's CI sandbox, where [feat] Run benchmark suites as workflows #1180 already runs trials.
--try,source.fixandcheck.addthemselves: [feat] Run benchmark suites as workflows #1180 adds them, to the contract in Notes.- Turning a session another member archived into a task. The
draftstep needs the session that fixed the bug; a transcript reader is a separate issue. - Offering
session-to-taskautomatically, for example from the friction reminder. [feat] Default workflows a team can override, starting with session-close #1388's reminder offerssession-closeonly. - A results page for tasks or suites.
Notes
- This issue replaces the "Second slice:
session-to-task" that [feat] Default workflows a team can override, starting with session-close #1388 carried, with its examples and acceptance. From [feat] Default workflows a team can override, starting with session-close #1388 it reuses only default-workflow resolution and the describevalueandwhyseam. - From [feat] Run benchmark suites as workflows #1180 (PR feat(workflow): run benchmark suites as workflows #1379), needed here. [feat] Run benchmark suites as workflows #1180 carries this contract and the two
--trymessages verbatim ("check passes without changes at ", "check fails at the fix "):check.add, a list of repo files that requiressource.fix: one path each with no glob, each matched by aprotectentry as git matches:(glob)pathspecs. Their content comes fromsource.fix, whether the starting commit has the file or not. The bytes are read fromsource.fixin the first of the trial'ssourcesthat has it (the run's checkout, then the commit cache) into an index of its own, asrestoreProtectedreadsstartingCommit(readStarting,src/workflow/benchmark/trial.ts). The fix never enters the agent's clone:freshClonefetches onlystartingCommitand its ancestors, so the clone has no copy offixto read and none for the agent to find, andgit checkout <fix> -- <paths>in the clone fails. A trial's check runsrestoreProtected, writes theaddpaths from that index, then checks them byte for byte asrestoreProtectedchecks the protected paths. The baseline writes them the same way afterfreshCloneand before setup.startmakes suresource.fixis reachable for a task withcheck.add(unreachableTask,src/workflow/benchmark/start.ts);--trychecks it for any task that sets it (firstUnreachable, same file), and the check and baseline terms (src/workflow/benchmark/terms.ts) carryaddandfix. Anaddpath that is not a file atfixfails the checktrial_prepare_failed, naming the path and the commit. Pull andreview --branchrefuse a glob, a path noprotectentry matches, oraddwithoutsource.fix. Same.strict()reason: first release.source.fix, an optional full commit in the task file'ssource(TaskFileSchema,src/workflow/benchmark/suite.ts). The schema is.strict(), so a teamai without the field rejects a file that has it; it ships with [feat] Run benchmark suites as workflows #1180's first release.teamai workflow start <suite> --benchmark --try <task file>: reads the task file and thesuite.yamltwo directories up from it, not the pulled cache, and needs noteamai init; the leaf name checks that suite'sname. Like [feat] Run benchmark suites as workflows #1180's CI job it runs in a checkout of the suite'srepoand clones from it (freshClone,src/workflow/benchmark/trial.ts). It makes surestartingCommitand, when set,source.fixare reachable (firstUnreachable: the checkout, else a fetch by SHA from the suite'srepointo a temporary cache). Anaddpath that is not a file atsource.fixexits non-zero naming the path and the commit, before setup. It runs setup and the check atstartingCommitwith thecheck.addfiles written fromsource.fix, where the check must fail by its own exit, and, withsource.fix, setup and the check atfixafterrestoreProtectedputs theprotectpaths back asstartingCommithas them and thecheck.addfiles are written again, where it must pass. A setup failure or a check classifiedcould_not_run(checkVerdict,src/workflow/benchmark/check.ts) at either commit exits non-zero naming the commit and cause. WithsuccessCriteriait validates the files, runs nothing, exits 0 and says there is no check to try. No arms, trials, judges or record. Needs--sandboxlike trials.
- Why the check is not tried inside the workflow: every command step carries the attempt marker (
attemptEnv, defined insrc/workflow/attempt-marker.ts, passed by the local driver insrc/workflow/drivers/local/local-driver.tsand by the skill runner insrc/workflow/drivers/skill/skill-runner.ts), so a nestedteamai workflow start … --benchmarkis refusednested_workflow(nestedStart,src/workflow/attempt-marker.ts). The fresh clone at a commit and theprotectrestore exist only astrialterms of [feat] Run benchmark suites as workflows #1180's generated plan (compilePlan,src/workflow/benchmark/plan.ts), which a YAML step cannot declare. - Interactive steps: worked by the session's own agent with the person, exempt from the read-only check (
docs/guide/workflow.md, "interactive").session-close'slearningstep is the precedent ([feat] Default workflows a team can override, starting with session-close #1388). - describe's input seam:
inputsOf(src/workflow/drivers/skill/preflight.ts, feat(workflow): add session discovery and managed public leases #1304), extended withvalueandwhyby [feat] Default workflows a team can override, starting with session-close #1388, keyed by the resolved workflow's name; this issue adds thesession-to-taskentry besidesession-close's. The caller's session:agentSessionFromEnv(src/utils/session-id.ts);sourcefrom its family throughplatformForTool(src/session-flow/agent-session.ts). start: dashboard events, read withreadEvents(src/dashboard-collector.ts). Take the calling session's earliestsession_start, then its optionalgitAtStart.head(HeadStateis{ head, dirty },src/utils/git.ts), written byaddGitStateAtStart(src/dashboard-collector.ts). Resume and compaction also write asession_start, so "earliest" matters.collectSession(src/session-collector.ts) picks the earliest start that has git state, which is a different rule; do not reuse it unchanged. Session captures (src/session-flow/captures.ts,agent.project.commit) are a different store.fix:getHeadState(cwd)(src/utils/git.ts).suite:getRemoteUrl(cwd)andremotesMatch(src/utils/git.ts) against each resolved cached suite'srepo. feat(workflow): run benchmark suites as workflows #1379 exposesloadCachedSuite(src/workflow/cache.ts) but no enumeration; add alistCachedSuitesbeside it, resolving namespaces asloadCachedSuitedoes, and propose the directory of itsrelativePath.push --file --pathis an exact-file route insidepush. Copy the source bytes to the destination and keep the source. Reuse push's scope, lock, self-mode worktree (withKnowledgeWorktree,src/utils/reports-branch.ts), provider and pull request code (createPrWithFallback,src/push.ts). Bypass scanner selection, resource-handler transformations (the skill handler rewrites frontmatter andCONTRIBUTORS,src/resources/skills.ts), namespace relocation, sweeper staging and pendingteamai.yamlinclusion (src/push.ts). Stage only the destination. Check containment with the existing path-safety helpers;pushRepoResourceItem(src/resources/repo-resource.ts) does not.--skill <path>andresolveRequestedSkill(src/push.ts) are the precedent for naming one resource.benchmarksis a placeable type from [feat] Run benchmark suites as workflows #1180 (src/push-namespaces.ts).
Activity
SaulMoro commentedon Oct 26, 2026
Added
check.add: the test the fix brought stays hidden from the trial's agent.Without it an agent can read the expected result and fit its change to it. Changes:
--tryat both commits).test-in-fixnow passes; newnot-addedrow fails atstartingCommitbecause no check runs the fix's test.draftprompt asks foradd, each path underprotect.check.addcontract for [feat] Run benchmark suites as workflows #1180 (underprotect, requiressource.fix, written afterrestoreProtected, first release because of.strict());--trywrites the files at both commits.check.add.SaulMoro commentedon Oct 26, 2026
Reviewed the
check.addpart against #1379's trial code. Changes:freshClonefetches onlystartingCommitand its history, sogit checkout <fix> -- <paths>in the trial clone fails (unable to read tree). Theaddfiles are now read fromsource.fixin the trial's sources into an index of their own, the same wayrestoreProtectedreadsstartingCommit. The fix never enters the agent's clone.startand--trymake suresource.fixis reachable, the same way they do forstartingCommit.add. It now takes every file underprotectthat the fix added or changed and the check reads, fixtures included. Before, it only took new test files. A case the fix added to an existing test file was otherwise reverted byrestoreProtected, and--trythen refused a valid task.not-addedrow. It now fails at the fix with "check fails at the fix". The check names a file that does not exist, so it already fails atstartingCommit. A newadd-missingrow covers a path that is not a file atfix.addpath must be committed infix;propose's approval. With the recommended--hitl recommendstart, the agent answers it, recorded as its answer. The example now picks "ask me at optional approvals", and a case row says so.startis not proposed when the session started dirty;addtakes exact paths, no globs;check.add;--trymessages.SaulMoro commentedon Oct 26, 2026
The
proposerow now says why its approval is optional and not required. The gate stays in the definition, so the engine records its answer and the dashboard shows it. Under the recommended start the agent answers it, and "ask me at optional approvals" makes it the person's. A required approval would send the person to their own terminal or the dashboard, which is a cost kept for what the team does not review, such as the transcript publish in #1388. A pull request is reviewed.SaulMoro commentedon Oct 26, 2026
start,fixandsuitenow carry awhyfrom describe (#1388), including when there is no value, for example: "No value: tracked files are modified. Commit the fix, then ask again, or give the commit."SaulMoro commentedon Oct 26, 2026
The default
descriptionnow says when to use it: "Use when the person asks to keep this fix as a benchmark task or add it to a benchmark suite." It becomes the description of the generatedworkflow-session-to-taskskill, which is how an agent finds the workflow.SaulMoro commentedon Oct 26, 2026
Fixes:
typeencoding.sessionhas its ownwhyfor this workflow.addGitStateAtStartin its own file.source.fixonly withcheck.add, while--trychecks it whenever it is set.SaulMoro commentedon Oct 26, 2026
The
--trynote now says it checks reachability withfirstUnreachableand a temporary cache, as thecheck.addnote already does.