Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b8535f33-08ac-44ef-84d1-aab96770ae3a
11 KiB
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_atintorelease_sessionat initialization. - Stage start -- set
status = 'in_progress'andstarted_atthe 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'andended_atthe 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). Leavestarted_atintact; 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'withstarted_atandended_atleft 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 + 1rather than reopening the old one or stretching itsended_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_atequal toprompted_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_atso 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).