Repository navigation
[feat] Publish and score sessions by run mode #1385
Description
Example
A team that sets nothing gets these defaults:
| Setting | attended |
unattended |
|---|---|---|
sessions.publish (teamai.yaml) |
ask, which acts as off until #1388 |
auto |
sessions (scorers/scoring.yaml) |
off |
auto |
So a person's sessions behave as today, and an unattended job publishes and judges its own sessions before it exits. The local driver drains at the end of the run:
$ CI=true teamai workflow start review-and-fix --driver local --tools default=claude --auto-approve
…
✔ Run r3f9… succeeded
ℹ Draining before exit (teamai score --drain)
✔ Judged 2 sampled passes here (review-quality)
✔ Published 2 labels to teamai-reports
✔ Archived 2 step sessions of r3f9… to teamai-reports (scrubbed)
✔ Reported usage to stats/<member>.yaml
✔ Nothing left to do hereEvery other driver ends with the same command: the skill driver in an unattended session, T3, Paseo, a CI script.
$ teamai score --drain
✔ Judged 1 sampled session here (review-quality)
✔ Published 1 label to teamai-reports
✔ Archived 1 captured session to teamai-reports (scrubbed)
✔ Reported usage to stats/<member>.yaml
ℹ Left open: 1 session whose scorer pins a model no judge tool here runs (judge_model_unavailable). A driver whose tools run it takes it with: teamai score --next --json '{"driver":{…,"can":{"judge":{…}}}}'
$ echo $?
0
$ teamai score --drain # the judge fails
✖ review-quality on claude-code session 7c1e…: judge_failed. A failed judgment is final and is not retried. To ask for a new one: teamai score --session 7c1e… --source claude-code --scorer review-quality
✔ Reported usage to stats/<member>.yaml
$ echo $?
1
$ teamai score --drain # run again
✔ Nothing left to do here
$ echo $?
0
$ teamai score --drain # the team repo cannot be pushed to
✔ Judged 1 sampled session here (review-quality)
✖ Could not publish 1 label and 1 session to teamai-reports: <git error>. They stay pending; run teamai score --drain again.
$ echo $?
1Where the drain runs decides what it sees of the calling session:
CI job: claude -p "…" → tool exits → teamai score --drain # the job's last step: the session is complete
skill: … last turn of an unattended session → teamai score --drain # the session as it stands now; later turns are
# neither judged nor published again
This changes behavior for a team that already runs teamai in CI or another unattended job. CHANGELOG.md gets this entry under ## [Unreleased] › ### 💥 Breaking Changes, and the guide says the same, with the opt-out:
- **Unattended runs now publish and score their sessions by default.** In CI, on a cloud VM or in a hosted job
(`CI` or `TEAMAI_MODE=unattended`), `teamai score --drain`, which the local driver runs at the end of a run and
every other driver runs before it ends, archives to teamai-reports, scrubbed: the captured sessions of this machine
recorded as unattended, and the step sessions of the workflow runs driven here. Every scorer with `sessions: true`
judges a sample of those captured sessions, within daily_limit. To keep the old behavior, set `unattended: off`:
teamai.yaml sessions: { publish: { unattended: off } }
scorers/scoring.yaml sessions: { unattended: off }
And under ### Changed:
- `teamai session push`, `session migrate` and `workflow publish --sessions` archive sessions on teamai-reports on
every team, as self mode already did. Sessions archived on the default branch before stay readable there.
The team files take a policy per mode. The boolean form of sessions keeps working:
# scorers/scoring.yaml, or scorers/<namespace>/scoring.yaml
sessions: { attended: auto, unattended: auto } # true = auto in both modes, false = off in both
daily_limit: 10 # per data home: on a fresh CI runner it caps one job, not one day# teamai.yaml
sessions:
publish: { attended: ask, unattended: auto } # the defaults, written outA member's own config.yaml wins over both, one mode at a time. A mode it does not name falls through to the team file:
sessions:
publish: { attended: ask } # offer to publish my sessions (#1388); unattended still follows the team
scoring:
sessions: { attended: ask } # offer to score my sampled sessionsA bad value is reported and ignored:
$ teamai doctor
⚠ teamai.yaml sessions.publish.unattended is "yes", not off, ask or auto, so it is ignored
⚠ Sessions are published and scored in unattended mode, but teamai reads no sessions from pi: they are neither published nor judgedBehavior
| Policy | attended |
unattended |
|---|---|---|
off |
nothing | nothing |
ask |
nothing is written; session-close offers it (#1388), on a Stop where the friction reminder fires, never on its own. Until #1388 lands it acts as off |
acts as off (nobody answers) |
auto |
done when the session is finished (6 hours idle), by the detached pass that already judges finished sessions | done by teamai score --drain, before the job exits |
| Setting | Resolution |
|---|---|
sessions.publish |
member config.yaml > teamai.yaml > default (ask attended, auto unattended). Not namespaced. Each mode resolves on its own |
sessions (scoring) |
member scoring.sessions > active namespace files, most restrictive first (off < ask < auto) > root scoring.yaml > default (off attended, auto unattended). Each mode resolves on its own |
A value that is not off, ask, auto (or a boolean for sessions) |
reported with file, key and allowed values, and ignored; that mode falls to the next level. Loading teamai.yaml or config.yaml never fails on it |
| Any value on an HTTP team | publish acts as off (no archive); doctor says so |
| What is judged | Governed by |
|---|---|
| A workflow run's step passes | The workflow's own step scorers, sampled by each scorer's samplingRate. Not by sessions |
A captured session (a person's, or a CI / TEAMAI_MODE session teamai did not launch) |
The sessions policy for that session's runMode (#1384), for every scorer with sessions: true, sampled |
| A session teamai launched (workflow step, judge) | Nothing: it writes no capture, and its step is judged as a pass |
| A session of a tool teamai reads no sessions from (Pi, Muse, Copilot and others) | Nothing: it is never captured. doctor names the tool when a policy for it is not off |
teamai score --drain, a new flag on the existing score command:
| Step | What it does |
|---|---|
| 1. Finished sessions | In unattended mode, every captured session of this data home recorded unattended counts as finished, the calling session included: the drain is the last step of the job, and an ephemeral runner gives no later chance. Run as the job's last step, after the tool has exited, the drain sees the session complete. Run from inside a session (the skill driver's unattended line), it judges and publishes that session as it stands at that moment, and its later turns are not judged or published again. Attended sessions keep the 6 hours rule |
| 2. Judge | Every open judge entry teamai can judge on this machine, with sessions finished per step 1: sampled step passes, and sampled sessions the policies allow. It declares localJudgeOffers() as its can.judge, as the background judge does, and judges each lease here as score --lease <ref> does. daily_limit still applies to sessions |
| 3. Publish labels | Every pending label, as score --push does |
| 4. Publish sessions | With publish: auto for the current mode: (a) every captured session of this data home whose runMode is that mode, that counts as finished under step 1 (unattended: all of them; attended: 6 hours idle) and that is not archived yet, through session push --session; (b) the step sessions of every run driven in this data home that are not archived yet, taken from each settled pass's tool and session, through archiveSessions; a pass whose tool has no session platform is listed as skipped. Both scrubbed, on teamai-reports |
| 5. Report usage | reportUsageToTeam, as teamai pull does, so the usage and outcome counts of this data home (#1384, #1387) leave an ephemeral runner |
| 6. Report | What is left: entries whose pinned model no judge tool here runs (judge_model_unavailable), or whose isolation this machine does not meet; they wait for another driver's score --next. skipped entries; anything that failed |
| Exit | 0 when nothing is left that can be done here, entries left for other drivers included. 1 when a judgment here failed or a publish failed. A failed publish stays pending, and the next --drain retries it. A failed judge attempt is final under the existing attempt rules: the drain prints it as failed and never retries it. A person can still ask for a new judgment with teamai score --session … --scorer … |
| Event | Today | After |
|---|---|---|
| A session counts as finished | 6 hours idle | attended: 6 hours idle, unchanged. unattended: when teamai score --drain runs in this data home. Never at Stop, never at SessionEnd |
A workflow run ends in unattended mode |
judge work waits for some driver's score --next |
the local driver calls the drain in code at done, and the run's exit status stays the run's outcome (a drain failure is printed). The skill driver and external drivers run teamai score --drain before they end |
session push, session migrate (archive mode), workflow publish --sessions |
git-kind teams commit to the team clone's default branch; self-mode teams commit to teamai-reports |
every git-kind and self-mode team commits to teamai-reports, under its lock |
session resume, score --team-session (#1269), benchmark replay (#1180), a workflow step that continues an archived session |
read the same place the writers use | read teamai-reports. When the session is not there, a git-kind team also reads the old archive on the default branch, read-only |
session list, session search, session pull |
read the same place the writers use | read both places on a git-kind team, teamai-reports first. A session in both is listed once, from teamai-reports |
Acceptance criteria
- The settings above parse in
teamai.yaml,scorers/scoring.yaml, namespacescoring.yamland memberconfig.yaml. A bad value is reported and ignored, assrc/scorers/settings.tsdoes today. - With no settings, attended sessions are neither published nor judged, as today. Covered by a test with no
CIand no settings. - With no settings, an unattended job's drain archives its captured sessions and its runs' step sessions, scrubbed, to
teamai-reports, and judges the captured sessions with every scorer that setssessions: true, sampled and withindaily_limit. Covered by a test withCI=trueand no settings. unattended: offinsessions.publishand inscoring.yamlsessionsrestores today's behavior in an unattended job. Covered by the same test with both set.- The boolean form of
sessionsinscoring.yamlandscoring.sessionsinconfig.yamlkeeps its meaning (true=autoin both modes,false=off). - A partial policy object (
{ attended: ask }) leaves the other mode to the next level. - Publishing always scrubs. There is no setting that publishes unscrubbed.
teamai session push --session <id> --source <platform> --scrubpushes exactly that session (todaypushtakes the most recent--limitsessions).--sourcetakes a session platform (claude-code,codebuddy-ide, …), assession pushandpulldo today.autopublishing of a captured session andsession-close([feat] Default workflows a team can override, starting with session-close #1388) use it. A run's step sessions are published witharchiveSessions, the writerworkflow publish --sessionsalready uses.teamai score --draindoes the steps in the table above, with those exit codes, in either mode. Inattendedmode it applies the attended policies. Covered by tests for: nothing to do (exit 0), an entry left open asjudge_model_unavailable(exit 0, printed), a failed publish (exit 1, retried by the next drain), a failed judgment (exit 1, printed, and a second drain exits 0 without judging it again), and a second drain that finds nothing new.- The drain reports usage as
teamai pulldoes. Covered by a test: an unattended session drained on a data home that never runspullreachesstats/<member>.yamlunderunattended. - In attended mode the drain publishes only attended sessions idle for 6 hours. Covered by a test with
publish: { attended: auto }and one active attended session, which stays unarchived. - The local driver calls the same function as
--drainatdonewhen its mode isunattended. There is one drain implementation. skill-data/workflow/SKILL.md(feat(workflow): add session discovery and managed public leases #1304) gains one line in its unattended paragraph: runteamai score --drainbefore your session ends.done.judgingkeeps feat(workflow): add session discovery and managed public leases #1304's shape, andworkflow nextkeeps never handing out judge work.sessionArchiveRootreturns theteamai-reportscheckout for every git-kind and self-mode team.openSessionArchive(restore) andalreadyArchivedread there first, then the legacy location. A step that continues a session another machine archived before this change still restores it.- In unattended mode, a session never gets a label before a drain reaches it. Covered by a test that runs Stop three times on a
TEAMAI_MODE=unattendedsession and finds no judge work untilteamai score --drainruns. - Attended
autopublishing runs in the detached pass that judges finished sessions (spawnDetachedScoreSessions,src/hook-handlers.ts). Covered by a test withpublish: { attended: auto }. - With a publish or scoring policy other than
off,teamai doctornames each enabled tool teamai reads no sessions from. Nothing for those tools is silently skipped. - The guide states the limitations: runs, judge attempts and leases live in the runner's data home, and a lapsed session lease or a failed judge attempt is final. A runner killed before its drain loses that judging. A drain run from inside a session judges and publishes it as it stands.
Definition of done
docs/guide/scorers.md(the policies and defaults, which setting governs passes and which sessions,score --drainand the rule that every unattended driver ends with it, the two places it runs (the job's last step sees the session complete; a drain from inside a session sees it as it stands, and its later turns are not judged or published again), that a failed judgment is final,daily_limitper data home next to theunattended: autodefault),docs/guide/dashboard.mdanddocs/guide/workflow-advanced.md(wheresession pushand the archive are documented; the drain at the end of an unattended run),docs/guide/sharing.md(sessions.publish) and their zh-CN pages. The team file and member override examples above go in these guide pages, and the behavior change with its opt-out comes first.CHANGELOG.mdunder## [Unreleased]: the two entries above, in### 💥 Breaking Changesand### Changed.docs/designs/session-user-repo-sync.md: the archive is onteamai-reports.skill-data/setup/references/setup-admin.md(the line that says reports and learnings useteamai-reportsandteamai-learnings): pushed sessions also live onteamai-reports.skill-data/workflow/SKILL.md: the one drain line above. No other skill text changes.session push,scoreandworkflow starthelp text covers--sessionon push,--drain, and that an unattended local run drains at the end;skill-data/core/references/commands.mdregenerated withnpx vitest run commands-reference -u.- Real-CLI record: one unattended
workflow start --driver localrun with no settings, showing judging, labels and archived sessions; the same run withunattended: off, showing none; oneteamai score --drainwith nothing left and one with a publish failure (exit 1); onesession push --sessionon a git-kind team landing onteamai-reports; onesession resumeof a session archived before the change.
Out of scope
- Offering
askto a person ([feat] Default workflows a team can override, starting with session-close #1388). - Moving archives that already exist on the default branch. They stay readable there.
- Recovering judge work from a runner killed before its drain.
- Reading sessions of tools teamai has no session adapter for.
- Answering approvals. Unattended mode answers no approval of the local driver and never turns
--auto-approveon. A CI job passes--auto-approveas today for optional approvals; without it they are answered no and their steps are skipped. A required approval fails its step withhitl_required, as it does in CI today (docs/guide/workflow.md, "Start from CI"), and a person retries it from a terminal withteamai workflow retry.
Notes
- Why
unattendeddefaults toauto: nobody is there to answerask, and an automation's sessions are the team's to review. A team that does not want that opts out withunattended: off. - Capture applies only to tools with a session adapter (
src/session-flow/adapters/platform-facts.ts). Pi, Muse, Copilot and others are not captured today, so no policy reaches their sessions. - The skill driver handles unattended runs itself (feat(workflow): add session discovery and managed public leases #1304):
--describestarts with--auto-approvewithout asking, and the run's own pass judging keeps running throughdone.judging. This issue adds the one drain line for what that loop does not cover (sessions and publishing). Nothing routes to the local driver.resolveRunModecomes from feat(workflow): add session discovery and managed public leases #1304, through [feat] Run mode: tell attended runs from unattended ones #1384, which depends on [feat] Add the session skill driver and invocable workflows #1086. - Unattended mode answers no approval of the local driver: with no HITL channel,
answerWithoutHitl(src/workflow/engine.ts) answers optional approvals no unless--auto-approveis given, and fails a required one withhitl_required. The skill driver's unattended--describestarts with--auto-approvebecause nobody is there to pick (feat(workflow): add session discovery and managed public leases #1304). - Drain loop to reuse:
scoreSessions(src/score-sessions.ts:43-68) already takes leases withtakeJudgeLeaseand judges them withjudgeLease, overlocalJudgeOffers()(:29). The drain runs that loop over passes and sessions, without its 30-minute deadline, then publishes. Label publishing: whatscore --pushdoes today (src/index.ts,scorecommand). Usage:reportUsageToTeam(src/team-push.ts:933), called bypullatsrc/pull.ts:3340. - Entries left for other drivers:
takeJudgeLeaseskips an entry whose scorer's model none of the driver's tools runs and leaves it open (judge_model_unavailable,SkippedEntryinsrc/workflow/scoring.ts). The judge tool does not depend on the tool that wrote the transcript, so the drain cannot name a tool a session needs. - Step sessions of a finished run: each settled pass reports its
toolandsession(src/workflow/step-state.ts:66-71).archiveSessionstakes session chains (src/session-flow/archive.ts:129-133); its only caller today builds them from unsettled steps (src/workflow/handoff.ts:293-308). - Archive location, one switch:
sessionArchiveRoot(src/session-flow/session-cmd-shared.ts:213-217). Self mode already returns the reports checkout there. Writers then go throughupdateReportsas self mode does (resolveRepoRoot,:226-250). Readers:openSessionArchive(src/session-flow/archive.ts:50-120, which today fetches the default branch throughfetchLatestat:105-120) andalreadyArchived(:191-205). Run handoff writer:archiveSessions(:129-189). Archive writer forsession migrate:src/session-flow/session-migrate-cmd.ts:441,:491.session pullrebuilds its indexes insrc/session-flow/session-cmd.ts:309-330. - No new branch:
pullrunsgit pull --ff-onlyon the full team clone (src/utils/git.ts:522-527), which fetches every head, so a separate branch would save no fetch. - Finished sessions:
FINISHED_AFTER_MS(src/session-flow/session-work.ts:138), applied at:265. Stop is registered for every tool; SessionEnd only for Copilot (src/builtin-hooks.ts:336-350). - Judge attempts: a session is judged once, so a released, lapsed or failed session attempt never opens it again (
sessionEntryState,src/scorers/entry.ts:87-103). A pass entry closes as failed on its first failed attempt (entryState,:59-80). Runs live in<dataHome>/workflows/runs(runsDir,src/workflow/store.ts), attempts in<dataHome>/scores(src/scorers/attempts.ts:1-5,:120-122). daily_limitis counted per data home (src/scorers/settings.ts:6-7), so on a fresh runner it caps one job, not one day.- Judge work:
takeJudgeLease, the requestJudgeRequest(itsentriesfield says "Never a session"), and the idle answernextJudgeLease, all insrc/workflow/scoring.ts. The drain takes every judge entry in the data home, including earlier runs' on a long-lived machine. feat(workflow): add session discovery and managed public leases #1304 added arunfilter for the skill driver; the drain does not need it. - The local driver returns at
doneindriveRun(src/workflow/drivers/local/local-driver.ts); the unattended drain goes there. - Today's background judge:
src/hook-handlers.ts:162-169(spawnDetachedScoreSessions),src/score-sessions.ts:43-68. - Scoring settings:
src/scorers/settings.ts:27-35(keys),:109-130(sessionScoring). Member keys:src/types.ts:900-907. Team config:src/types.ts:664-738. - Labels and stats share the
usageReportopt-out (isRecordingEnabled,src/workflow/records.ts;src/scorers/label-store.ts:121); unchanged. - Lease ref format:
s<13 hex>/a<8 hex>(LEASE_REF,src/scorers/entry.ts:20), for examples5d1e07c2a9b40/a91c0d7e0. - Unattended sessions become eligible only when a drain reaches them. A session no drain ever reaches stays unjudged and unpublished, under the documented runner-loss limitation. A long-lived VM runs
teamai score --drainat the end of each of its jobs.
Activity
SaulMoro commentedon Oct 25, 2026
Rewrote the body. Archives move to
teamai-reportsfor every team:sessionArchiveRootalready returns it in self mode (src/session-flow/session-cmd-shared.ts:213-217).Defaults: attended
askpublishing (acts asoffuntil #1388) andoffscoring; unattendedautofor both, since nobody answersaskthere. The release note and the guide state the change for teams already in CI, with the opt-outunattended: off.An unattended session is finished when its job drains
score --next, not at Stop, which fires every turn. A killed runner loses its judging, because a lapsed session lease is final (src/scorers/entry.ts:87-96); that is a documented limitation.doctornames the tools teamai reads no sessions from while a policy is on. An unattended skill-driven run drains through #1304's owndone.judgingloop, which also carries this issue's session work; nothing routes to the local driver.SaulMoro commentedon Oct 25, 2026
teamai score --drainflag judges what can run here, publishes labels and sessions, prints what is left, and exits 0 when nothing is left to do here. The local driver calls it atdone, and the skill driver's unattended paragraph gains one line to run it.done.judgingkeeps feat(workflow): add session discovery and managed public leases #1304's shape.publish: autocovers both the unattended captured sessions and the step sessions of runs driven here, and theCHANGELOG.mdentry says both.askis offered only together with the friction reminder ([feat] Default workflows a team can override, starting with session-close #1388).SaulMoro commentedon Oct 25, 2026
The drain no longer promises to retry a failed judgment. Judge attempts are final under the existing rules (
src/scorers/entry.ts), so only a failed publish stays pending for the next drain. A person can ask for a new judgment withscore --session.The drain now also reports usage, as
pulldoes, so unattended counts leave ephemeral runners. It treats sessions as finished before judging. In attended mode it publishes only sessions that are 6 hours idle.Documented both places the drain runs. As the job's last step it sees the session complete. From inside a session it judges and publishes that session as it stands.
The leftover line now names
judge_model_unavailableinstead of a tool. The approvals bullet now says what really happens: optional approvals are answered no without--auto-approve, and required ones fail withhitl_required.SaulMoro commentedon Oct 26, 2026
Updated a reference to #1304, which is now merged.
feat(dashboard): read whether a workspace's sessions are published an…
feat(dashboard): read whether a workspace's sessions are published an…
feat(dashboard): read whether a workspace's sessions are published an…
feat(session): publish and score sessions per run mode, and drain wha…