Jelajahi Sumber

B4: sessions.resume_notice — terminal session dropdown + resume-info panel

The sessions table gains a resume_notice text column (flattened M0001
amended; the database is tear-down-and-recreate); AllocationService
populates it at resume-allocation from the notice it builds — null for
fresh sessions. The terminal page's session list becomes an options
dropdown (newest first, 'Session #N — started <time> [resumed]'), and a
200px vertical-scroll panel above the terminal shows what the agent last
received for the selected session: the assignment's opening-brief
framing for session 1 (derived from the goal brief for runs, the mandate
otherwise), the stored resume_notice for later ones — markdown rendered
via stm-markdown, graceful empty state when neither exists. Switching
sessions navigates and reconnects the SSE as before. The suite's
terminal default assertion flips to newest-first rows.
clanker 1 bulan lalu
induk
melakukan
eae7936bdf

+ 8 - 1
src/Data/Entities.vala

@@ -234,6 +234,12 @@ namespace ClankerSaddle.Data {
         public DateTime? started_at { get; set; default = null; }
         public DateTime? ended_at { get; set; default = null; }
         public string? outcome { get; set; default = null; }
+        /**
+         * What a resumed session's agent first received (plan 14 B4):
+         * the resume_notice the allocation built, stored on the row at
+         * resume-allocation; null for fresh sessions.
+         */
+        public string? resume_notice { get; set; default = null; }
 
         public static void entity_mapping(EntityMapperBuilder<Session> cfg) {
             cfg.table("sessions")
@@ -242,7 +248,8 @@ namespace ClankerSaddle.Data {
                 .column<int64?>("saddle_id", o => o.saddle_id, (o, v) => o.saddle_id = v)
                 .column<DateTime?>("started_at", o => o.started_at, (o, v) => o.started_at = v)
                 .column<DateTime?>("ended_at", o => o.ended_at, (o, v) => o.ended_at = v)
-                .column<string>("outcome", o => o.outcome, (o, v) => o.outcome = v);
+                .column<string>("outcome", o => o.outcome, (o, v) => o.outcome = v)
+                .column<string>("resume_notice", o => o.resume_notice, (o, v) => o.resume_notice = v);
         }
     }
 

+ 6 - 0
src/Data/Migrations/M0001_Initial.vala

@@ -304,6 +304,12 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("started_at");
                 t.column<DateTime?>("ended_at");
                 t.column<string>("outcome");
+                // Plan 14 B4: what a RESUMED session's agent first
+                // received — the resume_notice the allocation built,
+                // stored on the session row at resume-allocation (null
+                // for fresh sessions; the terminal page shows it above
+                // the log).
+                t.column<string>("resume_notice");
             });
 
             b.create_table("session_commands", (t) => {

+ 13 - 0
src/Services/AllocationService.vala

@@ -369,6 +369,19 @@ namespace ClankerSaddle.Services {
                         resume_notice = TaskService.verdict_notice_for((!)newest);
                     }
                 }
+                // Plan 14 B4: persist what the resumed agent first
+                // received on the session row — the terminal page's
+                // resume-info panel reads it. Fresh sessions keep a
+                // null notice.
+                if (resume_notice != null && resume_notice.length > 0) {
+                    var resumed_row = yield db.query<Session>()
+                        .where(expr("id == $0", elem<int64?>(claim.session_id)))
+                        .first_async();
+                    if (resumed_row != null) {
+                        ((!)resumed_row).resume_notice = resume_notice;
+                        yield db.update_async<Session>((!)resumed_row);
+                    }
+                }
             }
 
             poll_state.note_push(claim.session_id);

+ 65 - 2
src/entrypoints/TaskTerminalEntrypoint.vala

@@ -16,6 +16,11 @@ namespace ClankerSaddle {
      * (the Statum entrypoint fetch never carries the page's query
      * string to the server, which is why only the first session used
      * to render) and connects the chosen session's SSE stream itself.
+     * Plan 14 B4: rows are NEWEST-first (the dropdown's order) and
+     * carry `resumed`/`notice` — the resume-info panel above the
+     * terminal shows the selected session's notice (the opening brief
+     * framing for the task's first session, the stored resume_notice
+     * for resumed ones).
      */
     public class TerminalSessionView : Object {
         public int64 id { get; set; }
@@ -23,6 +28,14 @@ namespace ClankerSaddle {
         public bool selected { get; set; }
         /** This session's raw PTY log exists (renders raw, not typed). */
         public bool raw { get; set; }
+        /** The session began as a resume (a stored resume_notice). */
+        public bool resumed { get; set; }
+        /**
+         * What the agent first received in this session (markdown):
+         * the opening-brief framing for the task's first session, the
+         * stored resume_notice for later ones, "" when none.
+         */
+        public string notice { get; set; default = ""; }
     }
 
     /** Public data of the `terminal` slot. */
@@ -56,6 +69,7 @@ namespace ClankerSaddle {
     public class TaskTerminalEntrypoint : StatumEntrypoint {
 
         private TaskService task_service = Inversion.inject<TaskService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -113,12 +127,36 @@ namespace ClankerSaddle {
             pub_with_session.comment = action_registry.author_private<AddCommentAction, TaskActionPrivate>(sealed);
 
             var session_rows = new Vector<TerminalSessionView>();
+            // Newest first (plan 14 B4): the dropdown leads with the
+            // latest session. The FIRST session (lowest id) shows the
+            // opening-brief framing; later ones their stored
+            // resume_notice.
+            int64 first_session = 0;
             foreach (var session in sessions) {
+                if (first_session == 0 || session.id < first_session) {
+                    first_session = session.id;
+                }
+            }
+            string opening_notice = yield opening_brief_notice(((!)detail).task);
+            var ordered = new Vector<ClankerSaddle.Data.Session>();
+            foreach (var session in sessions) {
+                ordered.add(session);
+            }
+            var session_array = ordered.to_array();
+            for (int i = session_array.length - 1; i >= 0; i--) {
+                var session = session_array[i];
+                bool resumed = session.resume_notice != null
+                    && ((!)session.resume_notice).length > 0;
+                string notice = session.id == first_session
+                    ? opening_notice : (session.resume_notice ?? "");
                 session_rows.add(new TerminalSessionView() {
                     id = session.id,
-                    label = @"#$(session.id) — $(format_time(session.started_at)) ($(session.outcome ?? "open"))",
+                    label = @"Session #$(session.id) — started $(format_time(session.started_at))"
+                        + (resumed ? " [resumed]" : ""),
                     selected = session.id == selected,
-                    raw = yield task_service.has_raw_log(session.id)
+                    raw = yield task_service.has_raw_log(session.id),
+                    resumed = resumed,
+                    notice = notice
                 });
             }
 
@@ -136,5 +174,30 @@ namespace ClankerSaddle {
         private static string format_time(DateTime? time) {
             return time != null ? ((!)time).format("%Y-%m-%d %H:%M") : "";
         }
+
+        /**
+         * The opening-brief framing for a task's FIRST session (plan
+         * 14 B4): what the assignment this session started with asked
+         * for — the goal's brief for a goal agent's run, the task's
+         * mandate otherwise (whichever carries text). "" when neither
+         * does (an empty-state panel).
+         */
+        private async string opening_brief_notice(TaskEntity task) throws GLib.Error {
+            string brief = task.description ?? "";
+            if (task.kind == TASK_KIND_GOAL_RUN && task.goal_id > 0) {
+                var goal = yield db.query<Goal>()
+                    .where(Invercargill.Expressions.expr("id == $0",
+                        Invercargill.Expressions.elem<int64?>(task.goal_id)))
+                    .first_async();
+                if (goal != null && ((!)goal).brief.length > 0) {
+                    brief = ((!)goal).brief;
+                }
+            }
+            if (brief.strip().length == 0) {
+                return "";
+            }
+            return "## Opening brief\n\nThe assignment this session started with:\n\n"
+                + brief;
+        }
     }
 }

+ 15 - 0
src/pages/app.css

@@ -714,6 +714,21 @@ nav .nav-goal { font-weight: 600; }
 
 [hidden] { display: none !important; }
 
+/* Plan 14 B4: the terminal page's resume-info panel — what the agent
+   last received for the selected session (the opening brief for
+   session 1, the stored resume notice for later ones), a 200px
+   vertical-scroll strip above the terminal. */
+.resume-notice-panel {
+    background: var(--goal-surface);
+    border: 1px solid var(--line-soft);
+    border-left: 4px solid var(--goal);
+    border-radius: 6px;
+    margin: 0.75rem 0;
+    max-height: 200px;
+    overflow-y: auto;
+    padding: var(--grid-2);
+}
+
 /* Plain terminal — looking over a colleague's shoulder */
 
 .terminal-plain {

+ 43 - 17
src/pages/task_terminal.html

@@ -57,15 +57,32 @@
                 }
 
                 function highlightPicker(sessionId) {
-                    var links = document.querySelectorAll('a[data-session-id]');
-                    for (var i = 0; i < links.length; i++) {
-                        var link = links[i];
-                        var mine = parseInt(link.getAttribute('data-session-id'), 10)
-                            === parseInt(sessionId, 10);
-                        link.className = mine ? '' : 'muted';
-                    }
+                    var select = document.getElementById('session-select');
+                    if (select) select.value = String(parseInt(sessionId, 10));
                 }
 
+                // Plan 14 B4: the dropdown + resume-info panel read the
+                // per-session rows (newest first) from state; the panel
+                // shows the selected session's notice — the opening
+                // brief for the first session, the stored resume_notice
+                // for resumed ones — rendered as markdown.
+                window.terminalSessionNotice = function (state) {
+                    var desired = requestedSessionId(state);
+                    var rows = (state && state.sessions) || [];
+                    for (var i = 0; i < rows.length; i++) {
+                        if (parseInt(rows[i].id, 10) === parseInt(desired, 10)) {
+                            return rows[i].notice || '';
+                        }
+                    }
+                    return '';
+                };
+
+                window.terminalPickSession = function (sessionId) {
+                    var task = statum.read('terminal');
+                    window.location.href = '/tasks/' + (task && task.task_id)
+                        + '/terminal?session_id=' + parseInt(sessionId, 10);
+                };
+
                 statum.addStateListener('terminal', function () {
                     var state = statum.read('terminal');
                     if (!state || !state.exists || !(state.session_id > 0)) return;
@@ -365,16 +382,25 @@
             <p><a stm-attribute.href="'/tasks/' + terminal.task_id" stm-text="'Back to task #' + terminal.task_id">task</a>
                 · session <strong stm-text="terminal.session_id">id</strong></p>
 
-            <p stm-if="terminal.sessions.length > 1">Sessions:
-                <span stm-for-session-in="terminal.sessions">
-                    <a stm-if="session.selected" data-session-id="session.id"
-                       stm-attribute.href="'/tasks/' + terminal.task_id + '/terminal?session_id=' + session.id"
-                       stm-text="session.label">session</a>
-                    <a stm-if="!session.selected" class="muted" data-session-id="session.id"
-                       stm-attribute.href="'/tasks/' + terminal.task_id + '/terminal?session_id=' + session.id"
-                       stm-text="session.label">session</a>
-                </span>
-            </p>
+            <!-- Plan 14 B4: the session picker is an options dropdown
+                 (newest first); above the terminal, a 200px
+                 vertical-scroll panel shows what the agent last
+                 received for the selected session — the opening brief
+                 for session 1, the stored resume notice for later
+                 ones (markdown, graceful empty state). -->
+            <p stm-if="terminal.sessions.length > 0">Session:
+                <select id="session-select" onchange="window.terminalPickSession(this.value)">
+                    <option stm-for-session-in="terminal.sessions" stm-attribute.value="session.id"
+                             stm-attribute.data-session-id="session.id"
+                             stm-text="session.label">session</option>
+                </select></p>
+
+            <div class="resume-notice-panel" stm-if="terminal.sessions.length > 0">
+                <div class="md" stm-if="window.terminalSessionNotice(terminal).length > 0"
+                     stm-markdown="window.terminalSessionNotice(terminal)">notice</div>
+                <p class="muted" stm-if="window.terminalSessionNotice(terminal).length == 0">
+                    No opening brief or resume notice recorded for this session.</p>
+            </div>
 
             <!-- Both containers stay in the DOM: the per-session raw
                  flag drives which one the client shows (see the

+ 10 - 2
test/web_stack_test.py

@@ -2012,8 +2012,8 @@ def bundle_pages(ctx):
     check(len(session_rows) >= 2
           and all(("raw" in s) for s in session_rows),
           "the terminal state lists every goal-run session with per-session raw flags")
-    check(int(terminal_state["session_id"]) == int(session_rows[-1]["id"]),
-          "the terminal default selects the LATEST session (was: the first)")
+    check(int(terminal_state["session_id"]) == int(session_rows[0]["id"]),
+          "the terminal default selects the LATEST session (rows are newest-first)")
     terminal_html = http_get_page(auth, "/tasks/%d/terminal" % dash_run)
     check("requestedSessionId" in terminal_html and "data-session-id" in terminal_html,
           "the terminal client owns the ?session_id= selection (query params never reach entrypoints)")
@@ -3347,6 +3347,14 @@ def bundle_allocation(ctx):
                    timeout=60, what="affinity goal plans") is not None,
           "the affinity goal reaches plan review (racer-two hosting)")
     fake2.goal_script = [("suspend",)]
+    # Freeze racer-two's ask triggers across the approval too (the
+    # later wake measurement already does): the mirror-sync fix makes
+    # the run resume-offerable the moment its goal row carries the
+    # verdict, so a wake-triggered background ask would race this
+    # manual ask_now for the claim.
+    fake2.ask_on_wake = False
+    fake2.ask_on_idle_status = False
+    fake2.settle_asks()
     act(auth, slot_public(auth, "/goals/%d" % affinity_goal, "goal")
         ["plan_pending_block"]["answer_action"],
         {"answer": "Plan approved. Proceed with implementation."})