Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

A session its harness left behind can be ended by hand from the site: under Live now, on a project's overview and the Work page, each session, active or idle, has a menu with End session for whoever runs it and for anyone with write access to the repository (nobody else sees it), and the session's page has an End session button for them; a confirm dialog says what happens and the list is read again once it has ended. It ends with end_reason "ended by hand by @name", its subagents still going end with it, and its claims stay until released or expired. end_session in the work service now lets someone with write (push) access end a session that is not theirs, recorded as ended by hand by them and without their reason or result (g1t_contracts::work::may_end_session and session_end_reason; EndSessionArgs takes by_hand, which the site's new endWorkSession sends); the Work feed reads such an end as "ended bo's session by hand". A session g1t runs on the workspace's Anthropic account is still stopped from its page. The API's end_session description, the OpenAPI copy and the sessions guide say so. Checked: cargo test -p g1t-contracts (321, new: who may end a session, the reason ended by hand), -p g1t-work (214), -p g1t-api (212, OpenAPI regenerated), -p g1t ops; cargo check -p g1t-integrations; apps/web tsc -b and node --test app/lib/coordination.test.ts (17, new: who may end one, the feed line). Not looked at in a browser.

G1t-Session:
ses_01m5087whgebarvkvxq9p955d8
syntaqxcommitted Parent3081106Browse files
14 files+353−290/14 viewed
+2−2
9898 match self {
9999 CoordinationOp::StartSession => "Start a session: one run of your own harness (a Claude Code session, a Codex run) on a repository, so others can see who is working on what. `harness` is claude_code, codex, cursor, copilot or other. Give `harness_session_id`, the harness's own id for the session, and starting again with it returns the session already started (refreshing the issue, pull request, branch, title and head given) instead of a new one, so a hook that fires twice is harmless. `issue` and `pull` attach it to an issue and a pull request of the repository; a pull request for an issue attaches that issue too. A subagent is a session of its own: give `parent_id` (one of your sessions here), its `agent_type`, the `task` and `prompt` its parent gave it, and as `harness_session_id` the parent's harness id, `/agent/` and the subagent's id; it starts on the parent's issue, pull request and branch unless it says otherwise, sees and is seen by its siblings in get_work and list_sessions, and once ended stays ended when started again. Returns the session (`id` `ses_…`, `state` active, idle or ended). A new session, or one started again after it ended, is a session.started event. Needs a confirmed email address and a role that may open issues.",
100100 CoordinationOp::UpdateSession => "Update one of your sessions: attach it to an issue or pull request, or change its branch, head commit or title. Only the fields given change. An empty body is a heartbeat: every call keeps the session active and renews the claims made from it. A session reads as idle after 15 minutes without one and ends (`end_reason` timed_out) after 30 minutes; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. A subagent's `task`, `prompt`, `model` and `result` (what it reported, up to 8,000 characters) can be set too; a subagent that ended takes them and stays ended. Returns the session; one ended with end_session is refused with `409`.",
101− CoordinationOp::EndSession => "End one of your sessions, with an optional `reason` and `result` (what it reported when it finished: a subagent's final message, up to 8,000 characters, never a tool's output). A session.ended event. Ending one already ended returns it as it is, taking `result` if it had none. Its subagents still going end with it (`parent ended`). Its claims are not released: release them, hand the issue off, or let them expire.",
101+ CoordinationOp::EndSession => "End one of your sessions, with an optional `reason` and `result` (what it reported when it finished: a subagent's final message, up to 8,000 characters, never a tool's output). A session.ended event. Ending one already ended returns it as it is, taking `result` if it had none. Its subagents still going end with it (`parent ended`). Its claims are not released: release them, hand the issue off, or let them expire. Anyone with write access to the repository may also end someone else's session, one left behind by a harness that was killed or never ended it: it ends as `ended by hand by @you`, without their `reason` or `result`.",
102102 CoordinationOp::GetSession => "A session and its entries, oldest first, numbered within the session (`seq`); `after` gives the entries after that number, 500 at a time. With them: `children`, its subagents and theirs in the order they started (each names its `parent_id`); `touched`, the files, commits, branches, pull requests and issues it and they touched; `links`, the issues and pull requests they worked on and how each was tied (`via`: attached, claim, branch, commit, trailer, opened or ref); `skills`, the skills they used by name; and for a subagent, `root`, the session at the top of its tree. `updates` contains up to 100 oldest pending coordination events that concern this session (id, kind, data, at): those about the issues and pull requests it is on or tied to, or every one while it is on nothing yet; never its own start, end or claims. Reading never acknowledges them; use record_session_entries with acknowledge IDs after considering them. To check for updates without the transcript, give `after` 4294967295. Anyone who can read the repository may.",
103103 CoordinationOp::ListSessions => "A repository's sessions, most recently heard from first, up to 100: `state` active (heard from in the last 15 minutes), idle or ended; `issue` or `pull` for those on one, attached or tied to it any other way; `path` for those that touched a file (or any under a folder ending in `/`). Subagents are left out unless `subagents` is true, which lists each tree whole. Each says how many `subagents` it started. Anyone who can read the repository may.",
104104 CoordinationOp::RecordSessionEntries => "Record entries in one of your sessions: up to 200 at a time, each with `kind` (prompt, message, tool_call, tool_result, note, or skill for a skill it used, by name), `text` (up to 64,000 characters), and for tools `tool`. Give each a `key` and recording it again does nothing, so a retried hook records it once. `touched` says what it touched, up to 200: `{ kind, value }` with kind file (a path from the repository's root), commit, branch, pull (one it opened) or issue (one it named); each is kept once, ties the session to the pull requests and issues it names, and a file another active session touched too (outside its own parents and subagents, so siblings yes) is an overlap.detected event for both, once per pair and again when a file it was not announced for joins. Entries of a session attached to a pull request show in that pull request's session too, where reviewers read why the change is what it is. `acknowledge` accepts up to 100 exact pending update IDs this session has considered; reading alone never acknowledges. Recording is a heartbeat. Returns how many were `recorded`, how many were `duplicates`, and how many of the `acknowledge` IDs were `acknowledged` (fewer than given means some were not this session's updates).",
390390 g1t_kit::call(work, "update_session", &args).await
391391 }
392392 CoordinationOp::EndSession => {
393− let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason"), result: text(input, "result") };
393+ let args = EndSessionArgs { actor: actor(), repo, id: id(), reason: text(input, "reason"), result: text(input, "result"), by_hand: false };
394394 g1t_kit::call(work, "end_session", &args).await
395395 }
396396 CoordinationOp::GetSession => {
+11−0
155155 | `idle` | Not heard from for 15 minutes. It may still come back. |
156156 | `ended` | Ended with `end_session`, or not heard from for 30 minutes (`end_reason` `timed_out`). A heartbeat brings a timed-out session back; an ended one stays ended. |
157157
158+A session its harness left behind, killed or never told to end, can be
159+ended by hand: **End session** in its menu under Live now (on the
160+project's overview and the Work page), or on its page. Whoever runs a
161+session can end it, and so can anyone with write access to the
162+repository; nobody else sees the action. It ends with `end_reason`
163+`ended by hand by @name`, its subagents still going end with it, and its
164+claims stay until they are released or expire. Through the API,
165+`end_session` on someone else's session does the same, without their
166+`reason` or `result`. A session g1t runs on the workspace's Anthropic
167+account is stopped from its page instead.
168+
158169 Entries of a session attached to a pull request show in that pull
159170 request's [session](/guides/why-blame/#sessions), beside what was recorded
160171 on it with `record_session`, so reviewers and why-blame see them. Entries
+0−0

Binary or large file; its contents are not shown.

+136−10
2020 Layers,
2121 Link2,
2222 MessageSquare,
23+ MoreHorizontal,
2324 Pencil,
2425 Play,
2526 Sparkles,
2728 Timer,
2829 TriangleAlert,
2930 } from "lucide-react";
30−import { Fragment, type ReactNode, useState } from "react";
31−import { Form, Link } from "react-router";
31+import { Fragment, type ReactNode, useEffect, useState } from "react";
32+import { Form, Link, useFetcher } from "react-router";
3233
3334 import {
3435 AGENT_MODES,
6061 type LiveIssue,
6162 awakeAndIdle,
6263 liveByIssue,
64+ mayEndSession,
6365 mergeSessions,
6466 numberedParts,
67+ type SessionEnder,
6568 sessionTitle,
6669 sessionTree,
6770 } from "../lib/coordination";
6871 import { PLUGIN_INSTALL } from "../lib/agent-setup";
6972 import { CopyLine, EmptyState, Input, SubmitButton, TimeAgo } from "./ui";
7073 import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
74+import {
75+ AlertDialog,
76+ AlertDialogCancel,
77+ AlertDialogContent,
78+ AlertDialogDescription,
79+ AlertDialogFooter,
80+ AlertDialogHeader,
81+ AlertDialogTitle,
82+} from "./ui/alert-dialog";
7183 import { Avatar } from "./ui/avatar";
7284 import { Badge, type BadgeTone } from "./ui/badge";
7385 import { Button } from "./ui/button";
7486 import { Card } from "./ui/card";
7587 import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
7688 import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
89+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
7790 import { Field, FieldError, FieldLabel, FieldLegend, FieldSet } from "./ui/field";
7891 import { Hint } from "./ui/hint";
7992 import { Label } from "./ui/label";
178191 );
179192 }
180193
194+/**
195+ * Asking before a session is ended by hand, for one its harness left
196+ * behind (killed, or never told to end): posts `end` to the session's
197+ * page, and closes once it has ended, with the page read again.
198+ */
199+export function EndSessionDialog({
200+ session,
201+ base,
202+ open,
203+ onOpenChange,
204+}: {
205+ session: WorkSession;
206+ base: string;
207+ open: boolean;
208+ onOpenChange: (open: boolean) => void;
209+}) {
210+ const fetcher = useFetcher<{ error?: string; ended?: boolean } | null>();
211+ const busy = fetcher.state !== "idle";
212+ const error = !busy ? fetcher.data?.error : undefined;
213+ useEffect(() => {
214+ if (fetcher.state === "idle" && fetcher.data?.ended) onOpenChange(false);
215+ }, [fetcher.state, fetcher.data, onOpenChange]);
216+ const subagents = session.subagents ?? 0;
217+ return (
218+ <AlertDialog open={open} onOpenChange={onOpenChange}>
219+ <AlertDialogContent>
220+ <fetcher.Form method="post" action={`${base}/work/sessions/${session.id}`} className="grid gap-5">
221+ <input type="hidden" name="action" value="end" />
222+ <AlertDialogHeader>
223+ <AlertDialogTitle>End {sessionTitle(session)}?</AlertDialogTitle>
224+ <AlertDialogDescription>
225+ For a session its harness left behind: killed, or never told to end. It shows as ended by hand by you
226+ {subagents > 0 ? `, and its ${subagents === 1 ? "subagent" : "subagents"} still going end with it` : ""}. Its claims stay until they are
227+ released or expire. If its harness is in fact still running, it may show again when it is next heard from.
228+ </AlertDialogDescription>
229+ </AlertDialogHeader>
230+ {error && (
231+ <Alert tone="danger">
232+ <AlertDescription>{error}</AlertDescription>
233+ </Alert>
234+ )}
235+ <AlertDialogFooter>
236+ <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
237+ <SubmitButton variant="destructive" fetcher={fetcher} pending="Ending…">
238+ End session
239+ </SubmitButton>
240+ </AlertDialogFooter>
241+ </fetcher.Form>
242+ </AlertDialogContent>
243+ </AlertDialog>
244+ );
245+}
246+
247+/** A session's ⋯ menu in a list, for whoever may end it: End session. */
248+function SessionMenu({ session, base }: { session: WorkSession; base: string }) {
249+ const [ending, setEnding] = useState(false);
250+ return (
251+ <>
252+ <DropdownMenu>
253+ <Hint label="More">
254+ <DropdownMenuTrigger asChild>
255+ <Button type="button" variant="ghost" size="icon-xs" aria-label={`More for ${sessionTitle(session)}`}>
256+ <MoreHorizontal size={15} />
257+ </Button>
258+ </DropdownMenuTrigger>
259+ </Hint>
260+ <DropdownMenuContent align="end">
261+ <DropdownMenuItem variant="destructive" onSelect={() => setEnding(true)}>
262+ <Square />
263+ End session
264+ </DropdownMenuItem>
265+ </DropdownMenuContent>
266+ </DropdownMenu>
267+ <EndSessionDialog session={session} base={base} open={ending} onOpenChange={setEnding} />
268+ </>
269+ );
270+}
271+
181272 /** One harness session: who, which harness, where it stands, when it was last heard from, linked to its page. */
182273 export function SessionLine({
183274 session,
184275 base,
185276 showWhere = true,
186277 showBranch = true,
278+ ender,
187279 }: {
188280 session: WorkSession;
189281 base: string;
191283 showWhere?: boolean;
192284 /** Its branch; not in a narrow sidebar. */
193285 showBranch?: boolean;
286+ /** Who is looking, for a menu to end it when they may. */
287+ ender?: SessionEnder;
194288 }) {
289+ const endable = ender != null && mayEndSession(session, ender.viewerId, ender.canPush);
195290 return (
196291 <div className="flex items-start gap-2.5">
197292 <Actor username={session.actor.username} harness={session.harness} />
224319 )}
225320 </p>
226321 </div>
322+ {endable && (
323+ <span className="-my-0.5 shrink-0">
324+ <SessionMenu session={session} base={base} />
325+ </span>
326+ )}
227327 </div>
228328 );
229329 }
353453 showWhere,
354454 showBranch,
355455 className,
456+ ender,
356457 }: {
357458 nodes: SessionNode[];
358459 base: string;
359460 showWhere?: boolean;
360461 showBranch?: boolean;
361462 className?: string;
463+ ender?: SessionEnder;
362464 }) {
363465 return (
364466 <ul className={className ?? "space-y-3"}>
365467 {nodes.map((node) => (
366468 <li key={node.session.id}>
367− <SessionLine session={node.session} base={base} showWhere={showWhere} showBranch={showBranch} />
469+ <SessionLine session={node.session} base={base} showWhere={showWhere} showBranch={showBranch} ender={ender} />
368470 {node.children.length > 0 && (
369471 <div className="pl-7.5">
370472 <SubagentBranch nodes={node.children} base={base} />
850952 }
851953
852954 /** One issue being worked on: its title, then each claim and session on it. */
853−export function LiveIssueCard({ group, title, base, now }: { group: LiveIssue; title: string | undefined; base: string; now: number }) {
955+export function LiveIssueCard({
956+ group,
957+ title,
958+ base,
959+ now,
960+ ender,
961+}: {
962+ group: LiveIssue;
963+ title: string | undefined;
964+ base: string;
965+ now: number;
966+ ender?: SessionEnder;
967+}) {
854968 return (
855969 <Card asChild divided>
856970 <li>
870984 ))}
871985 {group.sessions.map((session) => (
872986 <div key={session.id} className="px-4 py-3">
873− <SessionLine session={session} base={base} />
987+ <SessionLine session={session} base={base} ender={ender} />
874988 </div>
875989 ))}
876990 </li>
9441058 pulls,
9451059 base,
9461060 defaultBranch,
1061+ ender,
9471062 }: {
9481063 group: LiveGroup;
9491064 pulls: Record<number, LivePull>;
9501065 base: string;
9511066 defaultBranch?: string | null;
1067+ ender?: SessionEnder;
9521068 }) {
9531069 return (
9541070 <Card asChild divided>
9571073 {group.sessions.map((session) => (
9581074 <div key={session.id} className="px-4 py-3">
9591075 {/* Its pull request and branch are the heading's, unless it has neither of its own. */}
960− <SessionLine session={session} base={base} showWhere={group.kind === "none"} showBranch={group.kind === "none" && session.branch !== defaultBranch} />
1076+ <SessionLine
1077+ session={session}
1078+ base={base}
1079+ showWhere={group.kind === "none"}
1080+ showBranch={group.kind === "none" && session.branch !== defaultBranch}
1081+ ender={ender}
1082+ />
9611083 </div>
9621084 ))}
9631085 </li>
9801102 base,
9811103 now,
9821104 limit,
1105+ ender,
9831106 }: {
9841107 claims: Claim[];
9851108 sessions: WorkSession[];
9911114 base: string;
9921115 now: number;
9931116 limit?: number;
1117+ /** Who is looking: each session they may end has a menu to end it by hand. */
1118+ ender?: SessionEnder;
9941119 }) {
9951120 const { awake, idle } = awakeAndIdle(sessions);
9961121 const live = liveByIssue(claims, awake, defaultBranch);
9981123 return (
9991124 <ol className="space-y-3">
10001125 {issues.map((group) => (
1001− <LiveIssueCard key={group.issue} group={group} title={titles[group.issue]} base={base} now={now} />
1126+ <LiveIssueCard key={group.issue} group={group} title={titles[group.issue]} base={base} now={now} ender={ender} />
10021127 ))}
10031128 {live.groups.map((group) => (
10041129 <LiveGroupCard
10071132 pulls={pulls}
10081133 base={base}
10091134 defaultBranch={defaultBranch}
1135+ ender={ender}
10101136 />
10111137 ))}
1012− {idle.length > 0 && <IdleSessions sessions={idle} base={base} />}
1138+ {idle.length > 0 && <IdleSessions sessions={idle} base={base} ender={ender} />}
10131139 </ol>
10141140 );
10151141 }
10191145 * an open tab, not work going on. Each moves back up when it is heard from,
10201146 * and drops off when it times out.
10211147 */
1022−function IdleSessions({ sessions, base }: { sessions: WorkSession[]; base: string }) {
1148+function IdleSessions({ sessions, base, ender }: { sessions: WorkSession[]; base: string; ender?: SessionEnder }) {
10231149 return (
10241150 <Card asChild divided>
10251151 <li>
10341160 <CollapsibleContent className="divide-y divide-line border-t border-line">
10351161 {sessions.map((session) => (
10361162 <div key={session.id} className="px-4 py-3">
1037− <SessionLine session={session} base={base} />
1163+ <SessionLine session={session} base={base} ender={ender} />
10381164 </div>
10391165 ))}
10401166 </CollapsibleContent>
+31−0
44 import type { Claim, Decision, G1tEvent, WorkSession } from "@g1t/contracts";
55
66 import {
7+ ENDED_BY_HAND,
78 checkHref,
9+ endReasonText,
810 evidenceOf,
911 expiresIn,
1012 feedLine,
1416 liveByIssue,
1517 sessionTitle,
1618 duration,
19+ mayEndSession,
1720 mergeSessions,
1821 numberedParts,
1922 parseBlockedBy,
259262 assert.equal(feedLine(event("session.appended", { repoId: "rep_1", number: 5, pullId: "p", count: 3 })), null);
260263 });
261264
265+test("a session is ended by hand by whoever runs it, or by anyone who can write", () => {
266+ const live = session("ses_a");
267+ assert.equal(mayEndSession(live, bo.id, false), true);
268+ assert.equal(mayEndSession(live, ana.id, false), false);
269+ assert.equal(mayEndSession(live, ana.id, true), true);
270+ assert.equal(mayEndSession(session("ses_b", { state: "idle" }), ana.id, true), true);
271+ // Ended already, or nobody signed in: nothing to offer.
272+ assert.equal(mayEndSession(session("ses_c", { state: "ended" }), bo.id, true), false);
273+ assert.equal(mayEndSession(live, null, true), false);
274+ // One g1t runs is stopped from its page, which stops the run too.
275+ assert.equal(mayEndSession(session("ses_d", { harness: "claude_managed_agents" }), bo.id, true), false);
276+ assert.equal(endReasonText("timed_out"), "timed out");
277+ assert.equal(endReasonText(`${ENDED_BY_HAND}ana`), "ended by hand by @ana");
278+ assert.equal(endReasonText(null), null);
279+});
280+
281+test("a session ended by hand says who ended it in the feed", () => {
282+ const own = feedLine(event("session.ended", { repoId: "rep_1", session: session("ses_a", { issue: 2, state: "ended", endReason: `${ENDED_BY_HAND}bo` }) }))!;
283+ assert.equal(own.who, "bo");
284+ assert.equal(own.text, "ended a session on #2 by hand");
285+ const theirs = feedLine(event("session.ended", { repoId: "rep_1", session: session("ses_a", { state: "ended", endReason: `${ENDED_BY_HAND}ana` }) }))!;
286+ assert.equal(theirs.who, "ana");
287+ assert.equal(theirs.harness, null);
288+ assert.equal(theirs.text, "ended bo's session by hand");
289+ const timedOut = feedLine(event("session.ended", { repoId: "rep_1", session: session("ses_a", { state: "ended", endReason: "timed_out" }) }))!;
290+ assert.equal(timedOut.text, "ended a session (timed out)");
291+});
292+
262293 test("sessions become trees by their parents; one whose parent is missing is a root", () => {
263294 const roots = sessionTree([
264295 session("ses_root"),
+44−0
8383 ended: { label: "Ended", hint: "Ended, or not heard from for 30 minutes. One that went quiet comes back when it is heard from again." },
8484 };
8585
86+// ── Ending a session by hand ────────────────────────────────────────────
87+
88+/** How the reason of a session ended by hand starts (g1t_contracts::work::ENDED_BY_HAND). */
89+export const ENDED_BY_HAND = "ended by hand by @";
90+
91+/**
92+ * Whether the viewer may end a session by hand, for one its harness left
93+ * behind: one not ended yet, their own, or anyone's when they have write
94+ * access (`canPush`) to the repository. The work service checks the same.
95+ * One g1t runs on the workspace's Anthropic account is stopped from its
96+ * page instead, which stops the run too.
97+ */
98+export function mayEndSession(
99+ session: Pick<WorkSession, "state" | "actor" | "harness">,
100+ viewerId: string | null | undefined,
101+ canPush: boolean,
102+): boolean {
103+ if (!viewerId || session.state === "ended" || session.harness === "claude_managed_agents") return false;
104+ return session.actor.id === viewerId || canPush;
105+}
106+
107+/** Who may end which session from a list: the viewer, and whether they may end anyone's. */
108+export type SessionEnder = { viewerId: string | null; canPush: boolean };
109+
110+/** Why a session ended, in words: "timed out", "ended by hand by @ana", or what its harness said. */
111+export function endReasonText(reason: string | null | undefined): string | null {
112+ if (!reason) return null;
113+ return reason === "timed_out" ? "timed out" : reason;
114+}
115+
86116 // ── Session titles ──────────────────────────────────────────────────────
87117
88118 /** The most of a prompt's intent that titles a session. */
414444 const { session } = event.data;
415445 const on = session.issue != null ? ` on #${session.issue}` : session.pull != null ? ` on #${session.pull}` : "";
416446 const why = event.type === "session.ended" && session.endReason === "timed_out" ? " (timed out)" : "";
447+ // Ended by hand: by whoever ran it, or by someone with write access for them.
448+ const byHand = event.type === "session.ended" && session.endReason?.startsWith(ENDED_BY_HAND) ? session.endReason.slice(ENDED_BY_HAND.length) : null;
449+ if (byHand) {
450+ const own = byHand === session.actor.username;
451+ return {
452+ ...base,
453+ type: event.type,
454+ who: byHand,
455+ harness: own ? session.harness : null,
456+ text: own ? `ended a session${on} by hand` : `ended ${session.actor.username}'s session${on} by hand`,
457+ number: session.issue ?? session.pull,
458+ tone: "neutral",
459+ };
460+ }
417461 return {
418462 ...base,
419463 type: event.type,
+3−0
311311 needs: rankNeeds(needs),
312312 claims: ok(claims) ?? [],
313313 sessions: [...(ok(active) ?? []), ...(ok(idle) ?? [])],
314+ // Who may end a session by hand from Live now: its own runner, or anyone with write access.
315+ ender: { viewerId: viewer?.id ?? null, canPush: can.push },
314316 liveLoaded: claims?.ok === true || active?.ok === true,
315317 // Sections that could not be read say so in their place, rather than
316318 // reading as empty: what needs someone signed in (their open pull
930932 base={base}
931933 now={Date.now()}
932934 limit={LIVE_SHOWN}
935+ ender={loaderData.ender}
933936 />
934937 {moreIssues > 0 && (
935938 <Link to={`${base}/work`} className="mt-2 inline-block px-1 text-xs text-muted hover:text-fg">
+38−7
55 import type { Pull, SessionLink, SessionTouch, State, TouchKind, WorkSession } from "@g1t/contracts";
66
77 import type { Route } from "./+types/work-session";
8−import { Actor, CoordinationFeed, HarnessBadge, SessionStateBadge } from "../../components/coordination";
8+import { Actor, CoordinationFeed, EndSessionDialog, HarnessBadge, SessionStateBadge } from "../../components/coordination";
99 import { SessionEntries } from "../../components/session-entries";
1010 import { EmptyState, SubmitButton, TimeAgo } from "../../components/ui";
1111 import { Alert } from "../../components/ui/alert";
1717 import { PersonLink } from "../../components/work";
1818 import { IssueIcon, PullIcon } from "../../components/work-icons";
1919 import { requireRepo } from "../../lib/access.server";
20−import { type SessionNode, TOUCH_KINDS, duration, harnessName, pendingLines, sessionTitle, sessionTree, touchedByKind, viaLabel } from "../../lib/coordination";
20+import {
21+ type SessionNode,
22+ TOUCH_KINDS,
23+ duration,
24+ endReasonText,
25+ harnessName,
26+ mayEndSession,
27+ pendingLines,
28+ sessionTitle,
29+ sessionTree,
30+ touchedByKind,
31+ viaLabel,
32+} from "../../lib/coordination";
2133 import { page } from "../../lib/meta";
2234 import { notFound } from "../../lib/not-found.server";
2335 import { repoPages } from "../../lib/repo-live";
4456 }
4557
4658 export async function loader({ params, context }: Route.LoaderArgs) {
47− const { viewer } = await requireRepo(context, params, "read");
59+ const { viewer, access } = await requireRepo(context, params, "read");
4860 const path = { namespace: params.owner, name: params.repo };
4961 const read = () => work.getWorkSession(path, params.id, viewer);
5062 // The session is what the page is about: a service that fails fails the
7789 ...found.value,
7890 // Only whoever started it stops it, and only the top of the tree.
7991 canStop: viewer?.id === session.actor.id && session.harness === MANAGED && session.state !== "ended" && !session.parentId,
92+ // One its harness left behind is ended by hand: by whoever runs it, or anyone with write access.
93+ canEnd: mayEndSession(session, viewer?.id, access.can.push),
8094 consoleUrl,
8195 };
8296 }
86100 const user = requireUser(context, request);
87101 await requireRepo(context, params, "read");
88102 const form = await request.formData();
89− if (form.get("action") !== "stop") return { error: "There is nothing to do here but stop the session." };
90− const result = await agentSessions.stop(user, { namespace: params.owner, name: params.repo }, params.id).catch(() => null);
103+ const path = { namespace: params.owner, name: params.repo };
104+ // Ending it by hand (from here, or a list's menu by fetcher); the work service checks who may.
105+ if (form.get("action") === "end") {
106+ const ended = await work.endWorkSession(user, path, params.id).catch(() => null);
107+ if (!ended) return { error: "The session could not be ended. Try again in a moment." };
108+ return ended.ok ? { ended: true } : { error: ended.error.message };
109+ }
110+ if (form.get("action") !== "stop") return { error: "There is nothing to do here but stop or end the session." };
111+ const result = await agentSessions.stop(user, path, params.id).catch(() => null);
91112 if (!result) return { error: "The session could not be stopped. Try again in a moment." };
92113 return result.ok ? null : { error: result.error.message };
93114 }
267288
268289 /** One harness session: who ran it, where, what it worked on, its subagents, what it touched and what it recorded. */
269290 export default function WorkSessionPage({ loaderData, actionData, params }: Route.ComponentProps) {
270− const { session, entries, updates = [], children = [], touched = [], links = [], skills = [], root, canStop, consoleUrl, limited } = loaderData;
291+ const { session, entries, updates = [], children = [], touched = [], links = [], skills = [], root, canStop, canEnd, consoleUrl, limited } = loaderData;
271292 const base = `/${params.owner}/${params.repo}`;
293+ const [ending, setEnding] = useState(false);
272294 const now = Date.now();
273295 // Any session, claim or overlap in the repository: a change names no session.
274296 useRepoLive({ match: repoPages.session() });
335357 </SubmitButton>
336358 </Form>
337359 )}
360+ {canEnd && (
361+ <>
362+ <Button variant="outline" size="xs" onClick={() => setEnding(true)}>
363+ <Square size={12} />
364+ End session
365+ </Button>
366+ <EndSessionDialog session={session} base={base} open={ending} onOpenChange={setEnding} />
367+ </>
368+ )}
338369 {consoleUrl && (
339370 <Button asChild variant="outline" size="xs">
340371 <a href={consoleUrl} target="_blank" rel="noreferrer">
355386 </span>
356387 <span>
357388 · {session.state === "ended" ? "ended" : "last heard from"} <TimeAgo at={session.endedAt ?? session.lastSeenAt} />
358− {session.endReason && ` (${session.endReason === "timed_out" ? "timed out" : session.endReason})`}
389+ {session.endReason && ` (${endReasonText(session.endReason)})`}
359390 </span>
360391 {session.branch && (
361392 <Link to={`${base}/tree/${session.branch}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg">
+5−3
3131 }
3232
3333 export async function loader({ params, context }: Route.LoaderArgs) {
34− const { viewer, repo } = await requireRepo(context, params, "read");
34+ const { viewer, repo, access } = await requireRepo(context, params, "read");
3535 const path = { namespace: params.owner, name: params.repo };
3636 // One round: who is on what, the queue, what happened, what landed.
3737 // Each section is a part of its own (lib/partial.ts): one whose service
7878 pulls,
7979 landed,
8080 defaultBranch: repo.defaultBranch,
81+ // Who may end a session by hand from Live now: its own runner, or anyone with write access.
82+ ender: { viewerId: viewer?.id ?? null, canPush: access.can.push },
8183 };
8284 }
8385
129131 }
130132
131133 export default function WorkPage({ loaderData, params }: Route.ComponentProps) {
132− const { live, claims, sessions, feed, titles, pulls, landed, defaultBranch } = loaderData;
134+ const { live, claims, sessions, feed, titles, pulls, landed, defaultBranch, ender } = loaderData;
133135 const queue = isFailed(loaderData.queue) ? null : loaderData.queue;
134136 const base = `/${params.owner}/${params.repo}`;
135137 const now = Date.now();
167169 ) : !working ? (
168170 <StartHere title="Nobody is working on this repository right now" />
169171 ) : (
170− <LiveNowList claims={claims} sessions={sessions} titles={titles} pulls={pulls} defaultBranch={defaultBranch} base={base} now={now} />
172+ <LiveNowList claims={claims} sessions={sessions} titles={titles} pulls={pulls} defaultBranch={defaultBranch} base={base} now={now} ender={ender} />
171173 )}
172174 </Section>
173175
+51−1
710710
711711 /// `end_session`. Returns `Outcome<Session>`; ending one already ended
712712 /// returns it as it is (taking `result` if it had none). Ending a session
713−/// ends its subagents still going.
713+/// ends its subagents still going. Whoever runs a session ends it, and so
714+/// may anyone with write access to the repository, for one left behind
715+/// (its harness killed, or never told to end): see [`may_end_session`] and
716+/// [`session_end_reason`].
714717 #[derive(Debug, Serialize, Deserialize)]
715718 pub struct EndSessionArgs {
716719 pub actor: User,
721724 /// What it reported when it finished.
722725 #[serde(default)]
723726 pub result: Option<String>,
727+ /// Ended by a person from the site, not by its harness: recorded as
728+ /// [`ended_by_hand`] whatever `reason` says.
729+ #[serde(default)]
730+ pub by_hand: bool,
731+}
732+
733+/// How every [`ended_by_hand`] reason starts.
734+pub const ENDED_BY_HAND: &str = "ended by hand by @";
735+
736+/// The `end_reason` of a session a person ended by hand (from the site,
737+/// or someone else's): `ended by hand by @name`.
738+pub fn ended_by_hand(username: &str) -> String {
739+ format!("{ENDED_BY_HAND}{username}")
740+}
741+
742+/// Whether `actor` may end a session `owner` runs: their own, or anyone's
743+/// when they have write access (`can_push`) to the repository.
744+pub fn may_end_session(owner: &str, actor: &str, can_push: bool) -> bool {
745+ owner == actor || can_push
746+}
747+
748+/// Why a session ended, as `end_session` records it: [`ended_by_hand`]
749+/// when a person ended it by hand or it was not theirs (so it never reads
750+/// as its harness's own words), else what the harness said, or `ended`.
751+pub fn session_end_reason(own: bool, by_hand: bool, actor: &str, given: Option<&str>) -> String {
752+ if by_hand || !own {
753+ return ended_by_hand(actor);
754+ }
755+ given.map(str::trim).filter(|reason| !reason.is_empty()).map_or_else(|| "ended".to_owned(), str::to_owned)
724756 }
725757
726758 /// `get_work_session`: a session and its entries after `after_seq`.
12411273 }
12421274
12431275 #[test]
1276+ fn a_session_is_ended_by_whoever_runs_it_or_by_anyone_who_can_write() {
1277+ assert!(may_end_session("usr_1", "usr_1", false));
1278+ assert!(may_end_session("usr_1", "usr_2", true));
1279+ assert!(!may_end_session("usr_1", "usr_2", false));
1280+ }
1281+
1282+ #[test]
1283+ fn a_session_ended_by_hand_says_who_ended_it() {
1284+ assert_eq!(session_end_reason(true, false, "ana", Some("done")), "done");
1285+ assert_eq!(session_end_reason(true, false, "ana", Some(" ")), "ended");
1286+ assert_eq!(session_end_reason(true, false, "ana", None), "ended");
1287+ assert_eq!(session_end_reason(true, true, "ana", Some("done")), "ended by hand by @ana");
1288+ // Someone else's: never in its harness's words.
1289+ assert_eq!(session_end_reason(false, false, "bo", Some("done")), "ended by hand by @bo");
1290+ assert!(ended_by_hand("bo").starts_with(ENDED_BY_HAND));
1291+ }
1292+
1293+ #[test]
12441294 fn a_session_goes_idle_then_ends_without_heartbeats() {
12451295 let start = 1_000_000;
12461296 assert_eq!(SessionState::at(false, start, start), SessionState::Active);
+1−0
565565 }),
566566 getWorkSession: (repo, id, viewer, afterSeq = 0) =>
567567 call("get_work_session", { repo, id, viewer, after_seq: afterSeq }),
568+ endWorkSession: (actor, repo, id) => call("end_session", { actor, repo, id, by_hand: true }),
568569 pullSessions: (repo, viewer, numbers) => call("pull_sessions", { repo, viewer, numbers }),
569570 listDecisions: (repo, number, viewer) => call("list_decisions", { repo, number, viewer }),
570571 };
+7−0
15761576 ): Promise<Result<WorkSession[]>>;
15771577 getWorkSession(repo: RepoPath, id: string, viewer: Viewer, afterSeq?: number): Promise<Result<WorkSessionDetail>>;
15781578 /**
1579+ * Ends a session by hand, from the site: the actor's own, or anyone's
1580+ * when they have write access to the repository. It ends as
1581+ * `ended by hand by @actor`, with its subagents still going
1582+ * (`end_session` with `by_hand`; g1t_contracts::work::session_end_reason).
1583+ */
1584+ endWorkSession(actor: User, repo: RepoPath, id: string): Promise<Result<WorkSession>>;
1585+ /**
15791586 * The harness sessions tied to some pull requests (at most 100), any way
15801587 * a session is tied to one; a subagent's as the session that started it.
15811588 * Only pull requests with any are listed.
+2−2
11451145 let _: Outcome<Session> = g1t_kit::call(
11461146 &self.work,
11471147 "end_session",
1148− &EndSessionArgs { actor: actor.clone(), repo: repo.clone(), id: session.id.clone(), reason: Some("could not start".to_owned()), result: Some(problem.clone()) },
1148+ &EndSessionArgs { actor: actor.clone(), repo: repo.clone(), id: session.id.clone(), reason: Some("could not start".to_owned()), result: Some(problem.clone()), by_hand: false },
11491149 )
11501150 .await?;
11511151 return Ok(Outcome::fail(FailureCode::Unavailable, problem));
12711271 let _: Outcome<Session> = g1t_kit::call(
12721272 &self.work,
12731273 "end_session",
1274− &EndSessionArgs { actor: run.actor(), repo, id: id.to_owned(), reason: Some(reason.to_owned()), result },
1274+ &EndSessionArgs { actor: run.actor(), repo, id: id.to_owned(), reason: Some(reason.to_owned()), result, by_hand: false },
12751275 )
12761276 .await?;
12771277 Ok(())
+22−4
55 //! back. Every update, and every batch of entries, is a heartbeat: it keeps
66 //! the session active, brings back one that timed out, and renews the
77 //! claims made from it. Only the person whose token started a session
8−//! changes it.
8+//! changes it; anyone with write access may also end it, for one left
9+//! behind (recorded as ended by hand by them).
910 //!
1011 //! A subagent is a session of its own with a parent (`parent_id`): a
1112 //! Claude Code subagent, a Managed Agents thread. It starts on what its
544545 self.resume(&repo, row, &a.actor, fields).await
545546 }
546547
548+ /// A session `actor` may end: their own, or anyone's when they have
549+ /// write access to the repository (one left behind by a harness that
550+ /// was killed or never said it ended).
551+ async fn endable_session(&self, repo: &Repo, actor: &User, id: &str) -> Result<Outcome<SessionRow>> {
552+ let Some(row) = self.session_row(&repo.id, id.trim()).await? else {
553+ return Ok(not_found());
554+ };
555+ let can_push = matches!(crate::allowed(Some(actor), repo, g1t_contracts::access::Capability::Push), Outcome::Ok(()));
556+ Ok(if may_end_session(&row.actor_id, &actor.id, can_push) {
557+ Outcome::Ok(row)
558+ } else {
559+ Outcome::fail(FailureCode::Forbidden, "Only whoever runs this session, or someone with write access to the repository, can end it.")
560+ })
561+ }
562+
547563 pub(crate) async fn end_session(&self, a: EndSessionArgs) -> Result<Outcome<Session>> {
548564 let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?);
549− let row = unwrap_or_fail!(self.own_session(&repo, &a.actor, &a.id).await?);
565+ let row = unwrap_or_fail!(self.endable_session(&repo, &a.actor, &a.id).await?);
566+ let own = row.actor_id == a.actor.id;
550567 let now = now_ms();
551568 let at = rfc3339(now);
552− let result = clip(a.result.as_deref(), MAX_RESULT_CHARS);
569+ // What it reported is its own to say: someone else ending it says nothing for it.
570+ let result = if own { clip(a.result.as_deref(), MAX_RESULT_CHARS) } else { None };
553571 if row.state == "ended" {
554572 // Ended already: it keeps what it reported, or takes it now.
555573 if row.result.is_none()
567585 }
568586 return Ok(Outcome::Ok(row.at(now)));
569587 }
570− let reason = tidy(a.reason.as_deref(), MAX_NOTE_CHARS).unwrap_or_else(|| "ended".to_owned());
588+ let reason = session_end_reason(own, a.by_hand, &a.actor.username, tidy(a.reason.as_deref(), MAX_NOTE_CHARS).as_deref());
571589 let ended_event = |session: &Session| g1t_contracts::events::marked(SessionEvent::of(session), Some(&a.actor));
572590 // Its subagents still going end with it, all in one change with
573591 // their updates. (One started meanwhile ends with it too, and is

Comments

Sign in to comment.