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
14 files+353−290/14 viewed
| 98 | 98 | match self { | |
| 99 | 99 | 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.", | |
| 100 | 100 | 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`.", | |
| 102 | 102 | 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.", | |
| 103 | 103 | 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.", | |
| 104 | 104 | 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).", | |
| ⋯ | |||
| 390 | 390 | g1t_kit::call(work, "update_session", &args).await | |
| 391 | 391 | } | |
| 392 | 392 | 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 }; | |
| 394 | 394 | g1t_kit::call(work, "end_session", &args).await | |
| 395 | 395 | } | |
| 396 | 396 | CoordinationOp::GetSession => { | |
| 155 | 155 | | `idle` | Not heard from for 15 minutes. It may still come back. | | |
| 156 | 156 | | `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. | | |
| 157 | 157 | ||
| 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 | + | ||
| 158 | 169 | Entries of a session attached to a pull request show in that pull | |
| 159 | 170 | request's [session](/guides/why-blame/#sessions), beside what was recorded | |
| 160 | 171 | on it with `record_session`, so reviewers and why-blame see them. Entries |
Binary or large file; its contents are not shown.
| 20 | 20 | Layers, | |
| 21 | 21 | Link2, | |
| 22 | 22 | MessageSquare, | |
| 23 | + | MoreHorizontal, | |
| 23 | 24 | Pencil, | |
| 24 | 25 | Play, | |
| 25 | 26 | Sparkles, | |
| ⋯ | |||
| 27 | 28 | Timer, | |
| 28 | 29 | TriangleAlert, | |
| 29 | 30 | } 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"; | |
| 32 | 33 | ||
| 33 | 34 | import { | |
| 34 | 35 | AGENT_MODES, | |
| ⋯ | |||
| 60 | 61 | type LiveIssue, | |
| 61 | 62 | awakeAndIdle, | |
| 62 | 63 | liveByIssue, | |
| 64 | + | mayEndSession, | |
| 63 | 65 | mergeSessions, | |
| 64 | 66 | numberedParts, | |
| 67 | + | type SessionEnder, | |
| 65 | 68 | sessionTitle, | |
| 66 | 69 | sessionTree, | |
| 67 | 70 | } from "../lib/coordination"; | |
| 68 | 71 | import { PLUGIN_INSTALL } from "../lib/agent-setup"; | |
| 69 | 72 | import { CopyLine, EmptyState, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 70 | 73 | 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"; | |
| 71 | 83 | import { Avatar } from "./ui/avatar"; | |
| 72 | 84 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 73 | 85 | import { Button } from "./ui/button"; | |
| 74 | 86 | import { Card } from "./ui/card"; | |
| 75 | 87 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | |
| 76 | 88 | import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 89 | + | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 77 | 90 | import { Field, FieldError, FieldLabel, FieldLegend, FieldSet } from "./ui/field"; | |
| 78 | 91 | import { Hint } from "./ui/hint"; | |
| 79 | 92 | import { Label } from "./ui/label"; | |
| ⋯ | |||
| 178 | 191 | ); | |
| 179 | 192 | } | |
| 180 | 193 | ||
| 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 | + | ||
| 181 | 272 | /** One harness session: who, which harness, where it stands, when it was last heard from, linked to its page. */ | |
| 182 | 273 | export function SessionLine({ | |
| 183 | 274 | session, | |
| 184 | 275 | base, | |
| 185 | 276 | showWhere = true, | |
| 186 | 277 | showBranch = true, | |
| 278 | + | ender, | |
| 187 | 279 | }: { | |
| 188 | 280 | session: WorkSession; | |
| 189 | 281 | base: string; | |
| ⋯ | |||
| 191 | 283 | showWhere?: boolean; | |
| 192 | 284 | /** Its branch; not in a narrow sidebar. */ | |
| 193 | 285 | showBranch?: boolean; | |
| 286 | + | /** Who is looking, for a menu to end it when they may. */ | |
| 287 | + | ender?: SessionEnder; | |
| 194 | 288 | }) { | |
| 289 | + | const endable = ender != null && mayEndSession(session, ender.viewerId, ender.canPush); | |
| 195 | 290 | return ( | |
| 196 | 291 | <div className="flex items-start gap-2.5"> | |
| 197 | 292 | <Actor username={session.actor.username} harness={session.harness} /> | |
| ⋯ | |||
| 224 | 319 | )} | |
| 225 | 320 | </p> | |
| 226 | 321 | </div> | |
| 322 | + | {endable && ( | |
| 323 | + | <span className="-my-0.5 shrink-0"> | |
| 324 | + | <SessionMenu session={session} base={base} /> | |
| 325 | + | </span> | |
| 326 | + | )} | |
| 227 | 327 | </div> | |
| 228 | 328 | ); | |
| 229 | 329 | } | |
| ⋯ | |||
| 353 | 453 | showWhere, | |
| 354 | 454 | showBranch, | |
| 355 | 455 | className, | |
| 456 | + | ender, | |
| 356 | 457 | }: { | |
| 357 | 458 | nodes: SessionNode[]; | |
| 358 | 459 | base: string; | |
| 359 | 460 | showWhere?: boolean; | |
| 360 | 461 | showBranch?: boolean; | |
| 361 | 462 | className?: string; | |
| 463 | + | ender?: SessionEnder; | |
| 362 | 464 | }) { | |
| 363 | 465 | return ( | |
| 364 | 466 | <ul className={className ?? "space-y-3"}> | |
| 365 | 467 | {nodes.map((node) => ( | |
| 366 | 468 | <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} /> | |
| 368 | 470 | {node.children.length > 0 && ( | |
| 369 | 471 | <div className="pl-7.5"> | |
| 370 | 472 | <SubagentBranch nodes={node.children} base={base} /> | |
| ⋯ | |||
| 850 | 952 | } | |
| 851 | 953 | ||
| 852 | 954 | /** 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 | + | }) { | |
| 854 | 968 | return ( | |
| 855 | 969 | <Card asChild divided> | |
| 856 | 970 | <li> | |
| ⋯ | |||
| 870 | 984 | ))} | |
| 871 | 985 | {group.sessions.map((session) => ( | |
| 872 | 986 | <div key={session.id} className="px-4 py-3"> | |
| 873 | − | <SessionLine session={session} base={base} /> | |
| 987 | + | <SessionLine session={session} base={base} ender={ender} /> | |
| 874 | 988 | </div> | |
| 875 | 989 | ))} | |
| 876 | 990 | </li> | |
| ⋯ | |||
| 944 | 1058 | pulls, | |
| 945 | 1059 | base, | |
| 946 | 1060 | defaultBranch, | |
| 1061 | + | ender, | |
| 947 | 1062 | }: { | |
| 948 | 1063 | group: LiveGroup; | |
| 949 | 1064 | pulls: Record<number, LivePull>; | |
| 950 | 1065 | base: string; | |
| 951 | 1066 | defaultBranch?: string | null; | |
| 1067 | + | ender?: SessionEnder; | |
| 952 | 1068 | }) { | |
| 953 | 1069 | return ( | |
| 954 | 1070 | <Card asChild divided> | |
| ⋯ | |||
| 957 | 1073 | {group.sessions.map((session) => ( | |
| 958 | 1074 | <div key={session.id} className="px-4 py-3"> | |
| 959 | 1075 | {/* 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 | + | /> | |
| 961 | 1083 | </div> | |
| 962 | 1084 | ))} | |
| 963 | 1085 | </li> | |
| ⋯ | |||
| 980 | 1102 | base, | |
| 981 | 1103 | now, | |
| 982 | 1104 | limit, | |
| 1105 | + | ender, | |
| 983 | 1106 | }: { | |
| 984 | 1107 | claims: Claim[]; | |
| 985 | 1108 | sessions: WorkSession[]; | |
| ⋯ | |||
| 991 | 1114 | base: string; | |
| 992 | 1115 | now: number; | |
| 993 | 1116 | limit?: number; | |
| 1117 | + | /** Who is looking: each session they may end has a menu to end it by hand. */ | |
| 1118 | + | ender?: SessionEnder; | |
| 994 | 1119 | }) { | |
| 995 | 1120 | const { awake, idle } = awakeAndIdle(sessions); | |
| 996 | 1121 | const live = liveByIssue(claims, awake, defaultBranch); | |
| ⋯ | |||
| 998 | 1123 | return ( | |
| 999 | 1124 | <ol className="space-y-3"> | |
| 1000 | 1125 | {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} /> | |
| 1002 | 1127 | ))} | |
| 1003 | 1128 | {live.groups.map((group) => ( | |
| 1004 | 1129 | <LiveGroupCard | |
| ⋯ | |||
| 1007 | 1132 | pulls={pulls} | |
| 1008 | 1133 | base={base} | |
| 1009 | 1134 | defaultBranch={defaultBranch} | |
| 1135 | + | ender={ender} | |
| 1010 | 1136 | /> | |
| 1011 | 1137 | ))} | |
| 1012 | − | {idle.length > 0 && <IdleSessions sessions={idle} base={base} />} | |
| 1138 | + | {idle.length > 0 && <IdleSessions sessions={idle} base={base} ender={ender} />} | |
| 1013 | 1139 | </ol> | |
| 1014 | 1140 | ); | |
| 1015 | 1141 | } | |
| ⋯ | |||
| 1019 | 1145 | * an open tab, not work going on. Each moves back up when it is heard from, | |
| 1020 | 1146 | * and drops off when it times out. | |
| 1021 | 1147 | */ | |
| 1022 | − | function IdleSessions({ sessions, base }: { sessions: WorkSession[]; base: string }) { | |
| 1148 | + | function IdleSessions({ sessions, base, ender }: { sessions: WorkSession[]; base: string; ender?: SessionEnder }) { | |
| 1023 | 1149 | return ( | |
| 1024 | 1150 | <Card asChild divided> | |
| 1025 | 1151 | <li> | |
| ⋯ | |||
| 1034 | 1160 | <CollapsibleContent className="divide-y divide-line border-t border-line"> | |
| 1035 | 1161 | {sessions.map((session) => ( | |
| 1036 | 1162 | <div key={session.id} className="px-4 py-3"> | |
| 1037 | − | <SessionLine session={session} base={base} /> | |
| 1163 | + | <SessionLine session={session} base={base} ender={ender} /> | |
| 1038 | 1164 | </div> | |
| 1039 | 1165 | ))} | |
| 1040 | 1166 | </CollapsibleContent> | |
| 4 | 4 | import type { Claim, Decision, G1tEvent, WorkSession } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { | |
| 7 | + | ENDED_BY_HAND, | |
| 7 | 8 | checkHref, | |
| 9 | + | endReasonText, | |
| 8 | 10 | evidenceOf, | |
| 9 | 11 | expiresIn, | |
| 10 | 12 | feedLine, | |
| ⋯ | |||
| 14 | 16 | liveByIssue, | |
| 15 | 17 | sessionTitle, | |
| 16 | 18 | duration, | |
| 19 | + | mayEndSession, | |
| 17 | 20 | mergeSessions, | |
| 18 | 21 | numberedParts, | |
| 19 | 22 | parseBlockedBy, | |
| ⋯ | |||
| 259 | 262 | assert.equal(feedLine(event("session.appended", { repoId: "rep_1", number: 5, pullId: "p", count: 3 })), null); | |
| 260 | 263 | }); | |
| 261 | 264 | ||
| 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 | + | ||
| 262 | 293 | test("sessions become trees by their parents; one whose parent is missing is a root", () => { | |
| 263 | 294 | const roots = sessionTree([ | |
| 264 | 295 | session("ses_root"), | |
| 83 | 83 | ended: { label: "Ended", hint: "Ended, or not heard from for 30 minutes. One that went quiet comes back when it is heard from again." }, | |
| 84 | 84 | }; | |
| 85 | 85 | ||
| 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 | + | ||
| 86 | 116 | // ── Session titles ────────────────────────────────────────────────────── | |
| 87 | 117 | ||
| 88 | 118 | /** The most of a prompt's intent that titles a session. */ | |
| ⋯ | |||
| 414 | 444 | const { session } = event.data; | |
| 415 | 445 | const on = session.issue != null ? ` on #${session.issue}` : session.pull != null ? ` on #${session.pull}` : ""; | |
| 416 | 446 | 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 | + | } | |
| 417 | 461 | return { | |
| 418 | 462 | ...base, | |
| 419 | 463 | type: event.type, | |
| 311 | 311 | needs: rankNeeds(needs), | |
| 312 | 312 | claims: ok(claims) ?? [], | |
| 313 | 313 | 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 }, | |
| 314 | 316 | liveLoaded: claims?.ok === true || active?.ok === true, | |
| 315 | 317 | // Sections that could not be read say so in their place, rather than | |
| 316 | 318 | // reading as empty: what needs someone signed in (their open pull | |
| ⋯ | |||
| 930 | 932 | base={base} | |
| 931 | 933 | now={Date.now()} | |
| 932 | 934 | limit={LIVE_SHOWN} | |
| 935 | + | ender={loaderData.ender} | |
| 933 | 936 | /> | |
| 934 | 937 | {moreIssues > 0 && ( | |
| 935 | 938 | <Link to={`${base}/work`} className="mt-2 inline-block px-1 text-xs text-muted hover:text-fg"> | |
| 5 | 5 | import type { Pull, SessionLink, SessionTouch, State, TouchKind, WorkSession } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | 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"; | |
| 9 | 9 | import { SessionEntries } from "../../components/session-entries"; | |
| 10 | 10 | import { EmptyState, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { Alert } from "../../components/ui/alert"; | |
| ⋯ | |||
| 17 | 17 | import { PersonLink } from "../../components/work"; | |
| 18 | 18 | import { IssueIcon, PullIcon } from "../../components/work-icons"; | |
| 19 | 19 | 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"; | |
| 21 | 33 | import { page } from "../../lib/meta"; | |
| 22 | 34 | import { notFound } from "../../lib/not-found.server"; | |
| 23 | 35 | import { repoPages } from "../../lib/repo-live"; | |
| ⋯ | |||
| 44 | 56 | } | |
| 45 | 57 | ||
| 46 | 58 | 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"); | |
| 48 | 60 | const path = { namespace: params.owner, name: params.repo }; | |
| 49 | 61 | const read = () => work.getWorkSession(path, params.id, viewer); | |
| 50 | 62 | // The session is what the page is about: a service that fails fails the | |
| ⋯ | |||
| 77 | 89 | ...found.value, | |
| 78 | 90 | // Only whoever started it stops it, and only the top of the tree. | |
| 79 | 91 | 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), | |
| 80 | 94 | consoleUrl, | |
| 81 | 95 | }; | |
| 82 | 96 | } | |
| ⋯ | |||
| 86 | 100 | const user = requireUser(context, request); | |
| 87 | 101 | await requireRepo(context, params, "read"); | |
| 88 | 102 | 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); | |
| 91 | 112 | if (!result) return { error: "The session could not be stopped. Try again in a moment." }; | |
| 92 | 113 | return result.ok ? null : { error: result.error.message }; | |
| 93 | 114 | } | |
| ⋯ | |||
| 267 | 288 | ||
| 268 | 289 | /** One harness session: who ran it, where, what it worked on, its subagents, what it touched and what it recorded. */ | |
| 269 | 290 | 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; | |
| 271 | 292 | const base = `/${params.owner}/${params.repo}`; | |
| 293 | + | const [ending, setEnding] = useState(false); | |
| 272 | 294 | const now = Date.now(); | |
| 273 | 295 | // Any session, claim or overlap in the repository: a change names no session. | |
| 274 | 296 | useRepoLive({ match: repoPages.session() }); | |
| ⋯ | |||
| 335 | 357 | </SubmitButton> | |
| 336 | 358 | </Form> | |
| 337 | 359 | )} | |
| 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 | + | )} | |
| 338 | 369 | {consoleUrl && ( | |
| 339 | 370 | <Button asChild variant="outline" size="xs"> | |
| 340 | 371 | <a href={consoleUrl} target="_blank" rel="noreferrer"> | |
| ⋯ | |||
| 355 | 386 | </span> | |
| 356 | 387 | <span> | |
| 357 | 388 | · {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)})`} | |
| 359 | 390 | </span> | |
| 360 | 391 | {session.branch && ( | |
| 361 | 392 | <Link to={`${base}/tree/${session.branch}`} className="inline-flex items-center gap-1 font-mono text-xs hover:text-fg"> | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | 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"); | |
| 35 | 35 | const path = { namespace: params.owner, name: params.repo }; | |
| 36 | 36 | // One round: who is on what, the queue, what happened, what landed. | |
| 37 | 37 | // Each section is a part of its own (lib/partial.ts): one whose service | |
| ⋯ | |||
| 78 | 78 | pulls, | |
| 79 | 79 | landed, | |
| 80 | 80 | 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 }, | |
| 81 | 83 | }; | |
| 82 | 84 | } | |
| 83 | 85 | ||
| ⋯ | |||
| 129 | 131 | } | |
| 130 | 132 | ||
| 131 | 133 | 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; | |
| 133 | 135 | const queue = isFailed(loaderData.queue) ? null : loaderData.queue; | |
| 134 | 136 | const base = `/${params.owner}/${params.repo}`; | |
| 135 | 137 | const now = Date.now(); | |
| ⋯ | |||
| 167 | 169 | ) : !working ? ( | |
| 168 | 170 | <StartHere title="Nobody is working on this repository right now" /> | |
| 169 | 171 | ) : ( | |
| 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} /> | |
| 171 | 173 | )} | |
| 172 | 174 | </Section> | |
| 173 | 175 | ||
| 710 | 710 | ||
| 711 | 711 | /// `end_session`. Returns `Outcome<Session>`; ending one already ended | |
| 712 | 712 | /// 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`]. | |
| 714 | 717 | #[derive(Debug, Serialize, Deserialize)] | |
| 715 | 718 | pub struct EndSessionArgs { | |
| 716 | 719 | pub actor: User, | |
| ⋯ | |||
| 721 | 724 | /// What it reported when it finished. | |
| 722 | 725 | #[serde(default)] | |
| 723 | 726 | 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) | |
| 724 | 756 | } | |
| 725 | 757 | ||
| 726 | 758 | /// `get_work_session`: a session and its entries after `after_seq`. | |
| ⋯ | |||
| 1241 | 1273 | } | |
| 1242 | 1274 | ||
| 1243 | 1275 | #[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] | |
| 1244 | 1294 | fn a_session_goes_idle_then_ends_without_heartbeats() { | |
| 1245 | 1295 | let start = 1_000_000; | |
| 1246 | 1296 | assert_eq!(SessionState::at(false, start, start), SessionState::Active); | |
| 565 | 565 | }), | |
| 566 | 566 | getWorkSession: (repo, id, viewer, afterSeq = 0) => | |
| 567 | 567 | call("get_work_session", { repo, id, viewer, after_seq: afterSeq }), | |
| 568 | + | endWorkSession: (actor, repo, id) => call("end_session", { actor, repo, id, by_hand: true }), | |
| 568 | 569 | pullSessions: (repo, viewer, numbers) => call("pull_sessions", { repo, viewer, numbers }), | |
| 569 | 570 | listDecisions: (repo, number, viewer) => call("list_decisions", { repo, number, viewer }), | |
| 570 | 571 | }; |
| 1576 | 1576 | ): Promise<Result<WorkSession[]>>; | |
| 1577 | 1577 | getWorkSession(repo: RepoPath, id: string, viewer: Viewer, afterSeq?: number): Promise<Result<WorkSessionDetail>>; | |
| 1578 | 1578 | /** | |
| 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 | + | /** | |
| 1579 | 1586 | * The harness sessions tied to some pull requests (at most 100), any way | |
| 1580 | 1587 | * a session is tied to one; a subagent's as the session that started it. | |
| 1581 | 1588 | * Only pull requests with any are listed. |
| 1145 | 1145 | let _: Outcome<Session> = g1t_kit::call( | |
| 1146 | 1146 | &self.work, | |
| 1147 | 1147 | "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 }, | |
| 1149 | 1149 | ) | |
| 1150 | 1150 | .await?; | |
| 1151 | 1151 | return Ok(Outcome::fail(FailureCode::Unavailable, problem)); | |
| ⋯ | |||
| 1271 | 1271 | let _: Outcome<Session> = g1t_kit::call( | |
| 1272 | 1272 | &self.work, | |
| 1273 | 1273 | "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 }, | |
| 1275 | 1275 | ) | |
| 1276 | 1276 | .await?; | |
| 1277 | 1277 | Ok(()) | |
| 5 | 5 | //! back. Every update, and every batch of entries, is a heartbeat: it keeps | |
| 6 | 6 | //! the session active, brings back one that timed out, and renews the | |
| 7 | 7 | //! 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). | |
| 9 | 10 | //! | |
| 10 | 11 | //! A subagent is a session of its own with a parent (`parent_id`): a | |
| 11 | 12 | //! Claude Code subagent, a Managed Agents thread. It starts on what its | |
| ⋯ | |||
| 544 | 545 | self.resume(&repo, row, &a.actor, fields).await | |
| 545 | 546 | } | |
| 546 | 547 | ||
| 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 | + | ||
| 547 | 563 | pub(crate) async fn end_session(&self, a: EndSessionArgs) -> Result<Outcome<Session>> { | |
| 548 | 564 | 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; | |
| 550 | 567 | let now = now_ms(); | |
| 551 | 568 | 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 }; | |
| 553 | 571 | if row.state == "ended" { | |
| 554 | 572 | // Ended already: it keeps what it reported, or takes it now. | |
| 555 | 573 | if row.result.is_none() | |
| ⋯ | |||
| 567 | 585 | } | |
| 568 | 586 | return Ok(Outcome::Ok(row.at(now))); | |
| 569 | 587 | } | |
| 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()); | |
| 571 | 589 | let ended_event = |session: &Session| g1t_contracts::events::marked(SessionEvent::of(session), Some(&a.actor)); | |
| 572 | 590 | // Its subagents still going end with it, all in one change with | |
| 573 | 591 | // their updates. (One started meanwhile ends with it too, and is | |
Comments
Sign in to comment.