csharp-sdk/.github/agents/release-manager/references/session-tracking.md
Jeff Handley 514cf68af1
Add a release-manager agent that orchestrates the release skills (#1794)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b8535f33-08ac-44ef-84d1-aab96770ae3a
2026-08-05 14:24:16 -07:00

11 KiB
Raw Blame History

Session Tracking

Track release stage progress and timing so the closing summary is accurate. Use the SQL tool for storage. Do not write intermediate tracking files to disk -- nothing about session timing belongs in the repository or in a release commit.

Schema

Create these tables once, at the start of the session, before any stage work begins.

CREATE TABLE IF NOT EXISTS release_session (
    key   TEXT PRIMARY KEY,
    value TEXT
);
-- Expected keys: version, base_branch, release_branch, pr_number, draft_release_url,
-- published_release_url, session_started_at,
-- child_session_id, child_worktree_path, child_branch

CREATE TABLE IF NOT EXISTS release_stages (
    stage       INTEGER NOT NULL,      -- 1..5
    attempt     INTEGER NOT NULL DEFAULT 1,
    name        TEXT NOT NULL,
    status      TEXT NOT NULL,         -- 'pending' | 'in_progress' | 'blocked' | 'done' | 'carried_over'
    started_at  TEXT,                  -- ISO-8601 local time
    ended_at    TEXT,
    notes       TEXT,
    PRIMARY KEY (stage, attempt)
);

CREATE TABLE IF NOT EXISTS release_interactions (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    stage        INTEGER NOT NULL,
    kind         TEXT NOT NULL,        -- 'gate' | 'question' | 'review' | 'decision'
    prompted_at  TEXT NOT NULL,        -- when you asked
    answered_at  TEXT,                 -- when the user's answer arrived
    outcome      TEXT,                 -- what they decided
    summary      TEXT
);

-- Unattended time: child-agent work, CI watches, workflow watches, index polling.
-- Without this, wait time can only be inferred from stage wall-clock, which
-- silently folds in discussion and rework.
CREATE TABLE IF NOT EXISTS release_waits (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    stage       INTEGER NOT NULL,
    kind        TEXT NOT NULL,         -- 'child_work' | 'ci' | 'workflow' | 'index' | 'other'
    reason      TEXT NOT NULL,
    started_at  TEXT NOT NULL,
    ended_at    TEXT,
    ref         TEXT                   -- run id, PR number, package, etc.
);

CREATE TABLE IF NOT EXISTS release_workflow_runs (
    run_id       TEXT PRIMARY KEY,
    stage        INTEGER NOT NULL,
    name         TEXT NOT NULL,
    head_sha     TEXT,
    started_at   TEXT,
    ended_at     TEXT,
    conclusion   TEXT
);

Seed the five stages up front:

INSERT OR IGNORE INTO release_stages (stage, attempt, name, status) VALUES
  (1, 1, 'Prepare',          'pending'),
  (2, 1, 'Review and merge', 'pending'),
  (3, 1, 'Publish',          'pending'),
  (4, 1, 'Release',          'pending'),
  (5, 1, 'Verify',           'pending');

Recording timestamps

Every timestamp comes from the current date/time available to you in the session. Use ISO-8601 local time, for example 2026-08-04T13:22:05-07:00. Never estimate a timestamp you could have recorded.

Prefer immutable external evidence over your own observation. You notice things late; the event itself has a real timestamp. Query it and use it:

Event Authoritative source
PR merged gh pr view {n} --json mergedAt
Release published gh release view v{version} --json publishedAt
Workflow run start/end gh run view {id} --json startedAt,updatedAt,conclusion
Commit created the commit's author date

Recording stage 4's end from the moment you noticed publication, rather than from publishedAt, inflates that stage by your entire polling interval. The same applies to a merge you detect on a later poll, and to workflow runs you attach to after they started.

  • Session start -- write session_started_at into release_session at initialization.
  • Stage start -- set status = 'in_progress' and started_at the moment you begin the stage's first substantive action (invoking the skill, or beginning a status assessment for a human-gate stage).
  • Stage end -- set status = 'done' and ended_at the moment the stage's gate is satisfied (PR opened, PR merged, draft created, release published, packages listed) -- not when the user next speaks.
  • Blocked -- set status = 'blocked' with a note when a stage cannot advance (red CI, an unresolved breaking-change decision, a failed release workflow). Leave started_at intact; the blocked span still counts toward that stage's wall-clock time.
  • Carried over -- when resuming a release and evidence shows a stage completed in an earlier session, record it as status = 'carried_over' with started_at and ended_at left NULL. Never invent durations for work you did not observe.
  • Rework -- when a stage that reached its gate has to be revisited (CI went red after the PR was opened, a corrective push, a re-run of a failed workflow), close the current attempt and insert a new row with attempt + 1 rather than reopening the old one or stretching its ended_at. One aggregate row per stage hides the shape of the time: a stage 2 that reads as "2h 22m" tells you nothing about how much was CI, how much was review, and how much was remediation.

Recording waits

Insert a release_waits row whenever you begin waiting on something that is not the user, and close it when the wait ends. Cover child-session work, CI watches, workflow watches, and index polling.

This is what makes the closing summary's split honest. Without it, wait time can only be inferred by subtracting interaction time from stage wall-clock, which quietly counts discussion, diagnosis, and rework as waiting.

A wait records unattended time only, so it must not overlap an interaction. These two clocks run in the same wall-clock window — a two-hour CI watch during which the user answers an eleven-minute gate is not two hours of waiting plus eleven minutes of interaction. Counting both in full makes the parts exceed the whole and drives the remainder negative:

total 120m − active 11m − waiting 120m = −11m

Keep them disjoint as you record, rather than reconciling later. When a wait is running and you turn to the user, close the wait, handle the interaction, then open a new wait row for the remainder. The same watch then yields two wait rows around the gate instead of one row swallowing it:

active interaction = sum of interaction intervals
waiting            = sum of wait intervals, none overlapping an interaction
unaccounted        = total − (active + waiting)

Before reporting, check that unaccounted is not negative. If it is, the rows overlap and the split is wrong — say so and report the measured totals plainly instead of publishing a negative remainder or clamping it to zero. A clamped number looks correct and hides the defect.

Report the unaccounted remainder rather than distributing it. A visible gap is information; a silently absorbed one is a wrong number.

Record every CI and release workflow run in release_workflow_runs as you watch it, using the run's own startedAt and updatedAt. This makes the longest-wait figure in the summary a lookup instead of a recollection. These rows are evidence about the run itself, so they are exempt from the non-overlap rule — never sum them into waiting.

Recording interactions

Insert a release_interactions row every time you put a gate, question, or review in front of the user: write prompted_at when you ask, and fill answered_at from the timestamp of their reply.

Close the open interaction before doing anything else with the user's reply. At most one row per stage should have a NULL answered_at at any moment -- the question you are currently waiting on. When a reply arrives, your first action is to UPDATE that row with answered_at and outcome; only then act on what they said. Deferring the update is how rows end up permanently NULL, because by the time the work is done the arrival time is gone.

UPDATE release_interactions
SET answered_at = '{reply-timestamp}', outcome = '{what they decided}'
WHERE id = (SELECT MAX(id) FROM release_interactions WHERE answered_at IS NULL);

Two failure modes to avoid, both of which produce numbers that look fine and are wrong:

  • Never write answered_at equal to prompted_at. A zero-duration interaction means the reply timestamp was unavailable, not that the user answered instantly. Leave it NULL and count the row as unmeasured.
  • Record choice-style prompts too. A gate answered by picking an option is still interaction; if the mechanism gives you no reply timestamp, log the row with NULL answered_at so it appears in the unmeasured count rather than vanishing.

At wrap-up, report how many interactions were measured and how many were not. That belongs in the narrative prose, not in the timing table: "~27m across six gates, four more unmeasured" is an honest floor, while the table's cells stay clean and carry only the ~ estimate marker.

Always set stage on the interaction row. The closing summary reports interaction time per stage alongside each stage's elapsed time, which is what makes the table actionable -- a 2h 22m stage costing ~11m of your attention reads very differently from one costing ~2h. That roll-up is only possible if every interaction is attributed when it is recorded:

SELECT stage,
       SUM(strftime('%s', answered_at) - strftime('%s', prompted_at)) AS measured_seconds,
       SUM(answered_at IS NULL) AS unmeasured
FROM release_interactions
GROUP BY stage;

A stage whose interactions all lack a usable reply timestamp reports —, not ~0m. ~0m means the stage had no interactions, or its measured intervals rounded to zero -- it asserts that the stage cost the user no material time, which is a claim you can only make from data you actually have.

The interval between prompted_at and answered_at is the user's think-and-respond time. Sum those intervals to estimate active user-interaction time. Do not treat the rest of the session as waiting by subtraction -- take waiting from release_waits and report the remainder as unaccounted.

Apply judgement when summing:

  • Discard or cap any single interval that clearly represents the user stepping away rather than engaging -- an overnight gap between a gate and its answer is wait time, not interaction time. Note in the summary that such a gap was excluded.
  • Long stretches where the user reviews a diff, release notes, or a PR are interaction time even though you were idle.
  • Always label the result as an estimate, and state the measured/unmeasured split alongside it.

Progress rail

Render the rail from the latest attempt of each stage in release_stages at every gating prompt:

[✓] 1 Prepare  →  [●] 2 Review and merge  →  [ ] 3 Publish  →  [ ] 4 Release  →  [ ] 5 Verify

Use ✓ for done, ● for in progress, ⚠ for blocked, ↩ for carried over, and a blank for pending. Add the current sub-step after the rail when one is active, for example current: waiting on CI (2 checks running).