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

224 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
```sql
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:
```sql
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.
```sql
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:
```sql
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)`.