fix(desktop): a historical-source tab states why it cannot send

A saved tab kept in recovery with reason historical_source_pending is
restored onto a native legacy runtime. That runtime continues the
transcript in place but has no durable session store, and every desktop
send carries a submission ID, so the controller refused each one with
"durable submission identity unavailable". The tab offered a composer
that could only fail, and the banner's import action reopened the same
source in place instead of preparing it (#10759, #10760).

- Turn admission refuses an identified submission to a native legacy
  runtime before it reaches the controller, with the typed session code
  historical_source_pending carried in the not-accepted RPC error.
- Tab and runtime meta report the historical source for such a runtime,
  so the frontend projects the tab as pending: the composer is disabled
  and says to import before sending.
- The banner action prepares the source (PrepareSession) and opens the
  resulting canonical session, reusing the source-update polling path.
- The controller's refusal is an exported sentinel,
  ErrSubmissionIdentityUnavailable, instead of an anonymous error.
This commit is contained in:
yhh committed 2026-09-25 05:14:54 -07:00
1 parent 7493b2814c
commit ffdd87ef8b
17 files changed
+229 -26

No files matched your search

@@ -18,6 +18,7 @@ let polledPreparation: SessionPreparationView | undefined;
let navigationEpoch = 0;
let cancelled = 0;
const opened: string[] = [];
const preparedSelectors: unknown[] = [];
const source = { hostId: "local", sourceKey: "legacy", path: "/fixture/legacy.jsonl" };
const host = installDesktopHostStub({
CheckHistoricalSourceUpdate: async () => ({ sourceKey: "legacy", status: "available", version: "v2", source, retryable: false }),
@@ -30,6 +31,11 @@ const host = installDesktopHostStub({
if (polledPreparation) return polledPreparation;
throw new Error("terminal preparation must not poll");
},
PrepareSession: async (selector: unknown) => {
preparedSelectors.push(selector);
return { operationId: "prepare-source", sourceKey: "legacy", status: "ready", revision: 8,
target: { hostId: "local", sessionId: "imported-source" }, retryable: false };
},
CancelSessionPreparation: async (operationId: string) => { cancelled++; return cancellation ? cancellation.promise : { operationId, sourceKey: "legacy", status: "cancelled", revision: 3, retryable: true }; },
});
const root = createRoot(document.getElementById("root")!);
@@ -117,10 +123,12 @@ await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners
tab={{ ...baseProps.tab, sessionId: undefined, ready: false, historicalSource: source }}
navigate={async intent => { pendingIntents.push(intent); }}
/></LocaleProvider>));
assert.deepEqual(pendingIntents, [], "restoring a legacy tab never starts preparation automatically");
assert.deepEqual(preparedSelectors, [], "restoring a legacy tab never starts preparation automatically");
assert.ok(document.body.textContent?.includes("Import this conversation to keep sending messages"), "the banner states why sending waits");
await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
assert.equal(pendingIntents.length, 1);
assert.equal((pendingIntents[0] as { kind: string }).kind, "resume-session", "explicit preparation uses the shared navigation owner");
assert.deepEqual(preparedSelectors, [{ source }], "the action prepares the source instead of reopening it in place");
assert.deepEqual(pendingIntents, [{ kind: "canonical-session", ref: { hostId: "local", sessionId: "imported-source" } }],
"a prepared source opens as its canonical session");
const [canonicalTab] = seedActiveTabMetaList([{ ...baseProps.tab, historicalSource: source }], baseProps.tab);
assert.equal(canonicalTab.historicalSource, undefined, "canonical metadata omission clears the old preparation state");
await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} tab={canonicalTab} /></LocaleProvider>));
@@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { initialState } from "../lib/useController";
import { applyHydrateErrorState } from "../lib/hydrateErrorState";
import { projectSessionAvailability, type SessionAvailability } from "../lib/sessionAvailability";
import { projectSessionAvailability, submitBlockReason, type SessionAvailability } from "../lib/sessionAvailability";
assert.deepEqual(projectSessionAvailability({empty:true}),{kind:"ready",source:"history"},"an empty welcome has no runtime to wait for");
assert.equal(projectSessionAvailability({}).kind,"loading","an unresolved selected source still reports loading");
@@ -26,6 +26,13 @@ const failedLocal = applyHydrateErrorState(readyLocal, "startup", "history read
assert.deepEqual(projectSessionAvailability({ local: { ...failedLocal, meta: {
...readyLocal.meta, ready: false, historicalSource: { hostId: "local", path: "/fixture/legacy.jsonl" },
} } }), { kind: "pending", source: "runtime" }, "unprepared history is a user choice, not a failed recovery");
const nativeHistory = projectSessionAvailability({ local: { ...readyLocal, meta: {
...readyLocal.meta, historicalSource: { hostId: "local", path: "/fixture/legacy.jsonl" },
} } });
assert.deepEqual(nativeHistory, { kind: "pending", source: "runtime" }, "a readable native history runtime still cannot accept sends");
const recordKey = ((key: string) => key) as Translator;
assert.equal(submitBlockReason(nativeHistory, recordKey), "sessionRecovery.importBeforeSend", "the disabled composer says why and what to do");
assert.equal(submitBlockReason(projectSessionAvailability({ local: readyLocal }), recordKey), undefined);
const noop = () => {};
const common: TranscriptSurfaceProjectionInput = {
hydrating: false, hydrateHistoryLoaded: undefined, hydratePlaceholderItems: undefined, hydratePlaceholderActive: false,
@@ -38,6 +38,7 @@ import { buildAppShellClassNames, buildSessionStatusBannerProps, buildSidebarReg
import { buildBottomRegionsProps, buildWorkspaceDockProps } from "./dockRegionBuilders";
import { buildOverlayHostProps } from "./overlayBuilders";
import { buildComposerSurface, buildDecisionFooterSurface, buildFooterTodo, buildFooterUndo } from "./decisionFooterBuilders";
import { submitBlockReason } from "../lib/sessionAvailability";
const WindowsWindowControls = lazy(() => import("./WindowsWindowControls").then((module) => ({ default: module.WindowsWindowControls })));
const DockLauncher = lazy(() => import("../components/DockLauncher").then((module) => ({ default: module.DockLauncher })));
@@ -405,8 +406,7 @@ export function AppRuntimeView(props: AppRuntimeViewProps) {
decisionActive: Boolean(decisionSurface),
runtimeTransitioning: presentationTransitioning,
controllerReady: controllerReady && session.transcript.availability.kind === "ready",
submitDisabledReason: session.transcript.availability.kind !== "ready" && session.transcript.availability.source !== "runtime"
? t("sessionRecovery.sendAfterRecovery") : undefined,
submitDisabledReason: submitBlockReason(session.transcript.availability, t),
showContextWindowRing: false,
},
base: conversationView.composer,
@@ -146,7 +146,9 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
const cancellingOperationRef = useRef("");
const activeHostId = activeRef?.hostId ?? "";
const activeSessionId = activeRef?.sessionId ?? "";
const activeKey = activeSessionId ? `${activeHostId}:${activeSessionId}` : "";
const historicalSource = tab?.historicalSource;
const activeKey = activeSessionId ? `${activeHostId}:${activeSessionId}`
: historicalSource ? `source:${tab?.id}:${historicalSource.sourceKey || historicalSource.path}` : "";
const activeKeyRef = useRef(activeKey);
activeKeyRef.current = activeKey;
const mounted = useRef(true);
@@ -174,7 +176,7 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
};
void run().catch(() => {});
return () => { current = false; };
}, [activeHostId, activeSessionId]);
}, [activeHostId, activeSessionId, activeKey]);
const dismissUpdate = () => {
if (update?.version) {
@@ -182,8 +184,17 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
}
setUpdate(null);
};
const importUpdate = async () => {
if (busy || !update?.source || !update.version || !app.PrepareHistoricalSourceVersion || !app.GetSessionPreparation) return;
const importUpdate = () => {
if (!update?.source || !update.version || !app.PrepareHistoricalSourceVersion) return;
const { source, version } = update;
return runPreparation(() => app.PrepareHistoricalSourceVersion!(source, version));
};
const importSource = () => {
if (!historicalSource || !app.PrepareSession) return;
return runPreparation(() => app.PrepareSession!({ source: historicalSource }));
};
const runPreparation = async (start: () => Promise<SessionPreparationView>) => {
if (busy || !app.GetSessionPreparation) return;
const expectedActive = activeKey;
const operation = ++updateOperation.current;
const navigationCurrent = captureNavigation?.() ?? (() => activeKeyRef.current === expectedActive);
@@ -191,7 +202,7 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
setBusy(true);
setUpdateError("");
try {
let view: SessionPreparationView = await app.PrepareHistoricalSourceVersion(update.source, update.version);
let view = await start();
while (current() && !terminalPreparation.has(view.status)) {
await new Promise(resolve => setTimeout(resolve, 300));
if (!current()) return;
@@ -233,14 +244,11 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
{!waiting && preparation.retryable && <button type="button" className="btn btn--small" onClick={() => void navigate({ kind: "resume-session", session: preparation.session })}>{t("common.retry")}</button>}
</div>;
}
if (tab?.historicalSource) return <div className="banner banner--warning banner--actionable" role="status">
<span className="banner__msg">{tab.topicTitle || m("historicalTitle")} · {m("historicalAvailable")}</span>
<span className="banner__hint">{m("historicalImportDescription")}</span>
if (historicalSource) return <div className={`banner ${updateError ? "banner--error" : "banner--warning"} banner--actionable`} role={updateError ? "alert" : "status"}>
<span className="banner__msg">{tab?.topicTitle || m("historicalTitle")} · {m("historicalAvailable")}</span>
<span className="banner__hint">{updateError ? <ErrorMessage error={updateError} /> : m(busy ? "historicalImporting" : "historicalImportToSend")}</span>
<span className="banner__spacer" />
<button id="reasonix-prepare-restored-session" type="button" className="btn btn--small" onClick={() => void navigate({ kind: "resume-session", session: {
source: tab.historicalSource, path: tab.historicalSource!.path, scope: tab.scope, workspaceRoot: tab.workspaceRoot,
topicId: tab.topicId, title: tab.topicTitle, preview: "", turns: 0, turnsState: "unknown", createdAt: 0, lastActivityAt: 0, modTime: 0, current: true, open: true,
} })}>{m("historicalImportOpen")}</button>
<button id="reasonix-prepare-restored-session" type="button" className="btn btn--small" disabled={busy} onClick={() => void importSource()}>{m("historicalImportOpen")}</button>
</div>;
if (!update) return null;
return <div className={`banner ${updateError ? "banner--error" : "banner--warning"} banner--actionable`} role={updateError ? "alert" : "status"}>
@@ -49,6 +49,7 @@ const messages = {
historicalSourceBusy: ["其他实例正在使用此会话,关闭后可重试。", "其他實例正在使用此對話,關閉後可重試。", "In use by another instance. Close it and retry."],
historicalImportFailed: ["导入失败,原件已保留。可重试或查看恢复详情。", "匯入失敗,原件已保留。可重試或檢視恢復詳情。", "Import failed. Original files are preserved; retry or review recovery details."],
historicalAvailable: ["尚未导入", "尚未匯入", "Not imported"],
historicalImportToSend: ["导入后才能继续发送消息,原始文件会保留。", "匯入後才能繼續傳送訊息,原始檔案會保留。", "Import this conversation to keep sending messages. The original file is kept."],
historicalSourceUpdated: ["旧来源有更新,可导入为独立分支", "舊來源有更新,可匯入為獨立分支", "Historical source changed. Import as a separate branch."],
historicalQueued: ["排队中", "排隊中", "Queued"],
historicalImporting: ["正在导入", "正在匯入", "Importing"],
@@ -1,3 +1,4 @@
import type { Translator } from "./i18n";
import type { State } from "./useController";
import type { RemoteSessionApi } from "./useRemoteSession";
@@ -30,3 +31,10 @@ export function projectSessionAvailability(input: { local?: LocalSession; remote
&& (!local.meta.runtime || local.meta.runtime.phase === "ready");
return { kind: ready ? "ready" : "loading", source: "runtime" };
}
/** The reason a disabled composer states, or none when readiness alone explains it. */
export function submitBlockReason(availability: SessionAvailability, t: Translator): string | undefined {
if (availability.kind === "pending") return t("sessionRecovery.importBeforeSend");
if (availability.kind !== "ready" && availability.source !== "runtime") return t("sessionRecovery.sendAfterRecovery");
return undefined;
}
+1
View File
@@ -295,6 +295,7 @@ export const en = {
"sessionRecovery.retryFailed": "Recovery failed. Check the details and try again.",
"sessionRecovery.details": "Details",
"sessionRecovery.sendAfterRecovery": "Send after recovery",
"sessionRecovery.importBeforeSend": "Import to send",
"sessionRecovery.contentAfterRecovery": "Session content will appear after recovery",
"takeover.bannerButton": "Take over session",
"takeover.title": "Take over this session",
+1
View File
@@ -286,6 +286,7 @@ export const zhTW: Record<DictKey, string> = {
"sessionRecovery.retryFailed": "恢復失敗,請查看詳情後重試。",
"sessionRecovery.details": "詳情",
"sessionRecovery.sendAfterRecovery": "恢復後可傳送",
"sessionRecovery.importBeforeSend": "匯入後可傳送",
"sessionRecovery.contentAfterRecovery": "恢復後顯示會話內容",
"takeover.bannerButton": "接管會話",
"takeover.title": "接管該會話",
+1
View File
@@ -296,6 +296,7 @@ export const zh: Record<DictKey, string> = {
"sessionRecovery.retryFailed": "恢复失败,请查看详情后重试。",
"sessionRecovery.details": "详情",
"sessionRecovery.sendAfterRecovery": "恢复后可发送",
"sessionRecovery.importBeforeSend": "导入后可发送",
"sessionRecovery.contentAfterRecovery": "恢复后显示会话内容",
"takeover.bannerButton": "接管会话",
"takeover.title": "接管该会话",
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"strings"
"reasonix/internal/agent"
"reasonix/internal/control"
)
const sessionOperationHistoricalSourcePending = "historical_source_pending"
// nativeHistoricalSource names the transcript a native legacy runtime continues
// in place. That runtime has no durable submission store, so the source must
// be prepared into a canonical session before it can accept an identified send.
func nativeHistoricalSource(ctrl control.SessionAPI, path, headID string) *SessionSourceRef {
native, ok := ctrl.(*control.Controller)
path = strings.TrimSpace(path)
if !ok || path == "" || !native.NativeLegacySession() {
return nil
}
path = agent.CanonicalSessionPath(path)
return &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: headID, SourceKey: desktopSourceKey(path, headID)}
}
// tabHistoricalSourceLocked requires a.mu; it reports a source whether the tab
// was restored as a preparation shell or runs the source natively.
func tabHistoricalSourceLocked(tab *WorkspaceTab) *SessionSourceRef {
if tab == nil {
return nil
}
if tab.HistoricalSource != nil {
return tab.HistoricalSource
}
return nativeHistoricalSource(tab.Ctrl, tab.currentSessionPath(), tab.SessionHeadID)
}
// identifiedSubmissionCheck runs under the turn admission locks, so the
// refusal names the same runtime the submission would have reached.
func identifiedSubmissionCheck(submissionID []string) func(control.SessionAPI) error {
if firstSubmissionID(submissionID) == "" {
return nil
}
return func(ctrl control.SessionAPI) error {
if nativeHistoricalSource(ctrl, ctrl.SessionPath(), "") == nil {
return nil
}
return newSessionOperationError(sessionOperationHistoricalSourcePending,
"This conversation is still a historical source. Prepare it as a session before sending new messages.")
}
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/provider"
)
func restoreHistoricalSourcePendingTab(t *testing.T) (*App, *WorkspaceTab, string) {
t.Helper()
app := newSavedTabReconcileTestApp(t)
t.Cleanup(app.closeSessionServices)
path := filepath.Join(config.SessionDir(), "pending.jsonl")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
legacy := agent.NewSession("system")
legacy.Add(provider.Message{ID: "old", Role: provider.RoleUser, Content: "original work"})
if err := legacy.Save(path); err != nil {
t.Fatal(err)
}
entry := desktopTabEntry{ID: "pending", Scope: "global", TopicID: "topic", SessionPath: path}
file := desktopTabsFile{Tabs: []desktopTabEntry{entry}, ActiveTab: entry.ID, TabOrder: []string{entry.ID}}
if err := os.MkdirAll(desktopConfigDir(), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(desktopConfigDir(), tabsFileName), mustMarshalJSON(t, file), 0o600); err != nil {
t.Fatal(err)
}
finishSavedTabMigration(app)
app.tabsRestored = make(chan struct{})
app.restoreOrBuildTabs()
tab := waitForTabReady(t, app, entry.ID)
native, ok := tab.Ctrl.(*control.Controller)
if !ok || !native.NativeLegacySession() || agent.CanonicalSessionPath(tab.SessionPath) != agent.CanonicalSessionPath(path) {
t.Fatalf("pending source did not restore as its native runtime: path=%q ctrl=%T", tab.SessionPath, tab.Ctrl)
}
return app, tab, path
}
func TestHistoricalSourcePendingTabRefusesSendWithTypedReason(t *testing.T) {
app, tab, path := restoreHistoricalSourcePendingTab(t)
err := app.SubmitToTabWithID(tab.ID, "continue", "submission-1")
var refusal *SessionOperationError
if !errors.As(err, &refusal) || refusal.Code != sessionOperationHistoricalSourcePending {
t.Fatalf("send was not refused with the historical-source reason: %v", err)
}
if !errors.Is(err, control.ErrSubmissionNotAccepted) {
t.Fatalf("refusal does not say the submission was not accepted: %v", err)
}
if errors.Is(err, control.ErrSubmissionIdentityUnavailable) {
t.Fatalf("send reached the controller before being refused: %v", err)
}
if tab.Ctrl.RuntimeStatus().Running {
t.Fatal("refused send started a turn")
}
meta := app.metaForTab(tab.ID)
if meta.HistoricalSource == nil || agent.CanonicalSessionPath(meta.HistoricalSource.Path) != agent.CanonicalSessionPath(path) {
t.Fatalf("meta does not report the pending source, so the composer stays enabled: %+v", meta.HistoricalSource)
}
for _, listed := range app.ListTabs() {
if listed.ID == tab.ID && listed.HistoricalSource == nil {
t.Fatal("tab list does not report the pending source")
}
}
}
func TestHistoricalSourcePendingTabPreparesIntoCanonicalSession(t *testing.T) {
app, tab, _ := restoreHistoricalSourcePendingTab(t)
source := app.metaForTab(tab.ID).HistoricalSource
if source == nil {
t.Fatal("meta does not report the pending source")
}
view, err := app.PrepareSession(SessionSelector{Source: source})
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(10 * time.Second)
for view.Status != "ready" && view.Status != "failed" && view.Status != "blocked" && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
if view, err = app.GetSessionPreparation(view.OperationID); err != nil {
t.Fatal(err)
}
}
if view.Status != "ready" || view.Target == nil {
t.Fatalf("pending source could not be prepared while its tab is open: %+v", view)
}
}
func TestNativeLegacyRuntimeRefusesIdentifiedSendBeforeController(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "native.jsonl")
ctrl := control.New(control.Options{Sink: &tabEventSink{tabID: "tab"}, SessionDir: dir, SessionPath: path, NativeLegacySession: true})
defer ctrl.Close()
tab := &WorkspaceTab{ID: "tab", Scope: "global", Ready: true, Ctrl: ctrl, sink: &tabEventSink{tabID: "tab"}, SessionPath: path}
app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
err := app.SubmitToTabWithID(tab.ID, "continue", "submission-1")
var refusal *SessionOperationError
if !errors.As(err, &refusal) || refusal.Code != sessionOperationHistoricalSourcePending {
t.Fatalf("send was not refused with the historical-source reason: %v", err)
}
if errors.Is(err, control.ErrSubmissionIdentityUnavailable) {
t.Fatalf("send reached the controller before being refused: %v", err)
}
}
+2 -1
View File
@@ -18,6 +18,7 @@ func (a *App) metaForTab(tabID string) Meta {
tab := a.tabByIDLocked(tabID)
snap := snapshotTabRuntimeLocked(tab)
runtimeView := a.sessionRuntimeViewLocked(tab)
historical := tabHistoricalSourceLocked(tab)
a.mu.RUnlock()
if tab == nil {
meta := Meta{EventChannel: eventChannel}
@@ -71,7 +72,7 @@ func (a *App) metaForTab(tabID string) Meta {
meta := Meta{
Label: snap.label, Ready: runtimeView.Phase == sessionRuntimeReady && snap.ctrl != nil,
Runtime: runtimeView, StartupErr: snap.startupErr, EventChannel: eventChannel,
HistoricalSource: snap.historicalSource,
HistoricalSource: historical,
SessionPath: sessionPath, SessionID: sessionID, Session: sessionRef,
SessionRevision: sessionRevision, SessionDigest: sessionDigest,
SessionGeneration: snap.sessionGeneration, RuntimeStateSnapshot: runtimeStateSnapshot,
+1 -1
View File
@@ -21,7 +21,7 @@ func (a *App) startModelApplicationTurn(tabID, submissionID string, req control.
if receipt, found, err := a.knownSubmissionReceipt(tabID, req); found || err != nil {
return TurnStartView{TurnID: receipt.TurnID, SubmissionID: submissionID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted}, err
}
admission, ctrl, err := a.beginRuntimeTurnWithModelChoice(tabID, true, false, nil, choice, submissionID)
admission, ctrl, err := a.beginRuntimeTurnWithModelChoice(tabID, true, false, identifiedSubmissionCheck([]string{submissionID}), choice, submissionID)
if err != nil {
return TurnStartView{}, a.submissionAdmissionError(tabID, req, err)
}
+1 -3
View File
@@ -11,7 +11,6 @@ type tabRuntimeSnapshot struct {
ready bool
readOnly bool
startupErr string
historicalSource *SessionSourceRef
scope string
workspaceRoot string
sessionPath string
@@ -43,8 +42,7 @@ func snapshotTabRuntimeLocked(tab *WorkspaceTab) tabRuntimeSnapshot {
return tabRuntimeSnapshot{
ctrl: tab.Ctrl, sink: tab.sink, label: tab.Label, ready: tab.Ready,
readOnly: tab.ReadOnly, startupErr: tab.StartupErr, scope: tab.Scope,
historicalSource: tab.HistoricalSource,
workspaceRoot: tab.WorkspaceRoot, sessionPath: tab.SessionPath, sessionID: tab.SessionID,
workspaceRoot: tab.WorkspaceRoot, sessionPath: tab.SessionPath, sessionID: tab.SessionID,
sessionGeneration: tab.SessionGeneration, topicID: tab.TopicID, topicTitle: tab.TopicTitle,
sharedHostKey: tab.SharedHostKey, model: tab.model, effort: cloneStringPtr(tab.effort),
tokenMode: currentTabTokenMode(tab), qualityFloor: tab.qualityFloor, mode: tab.mode,
+1 -1
View File
@@ -2187,7 +2187,7 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
Goal: currentTabGoal(tab),
GoalStatus: currentTabGoalStatus(tab),
StartupErr: tab.StartupErr,
HistoricalSource: tab.HistoricalSource,
HistoricalSource: tabHistoricalSourceLocked(tab),
Active: active,
Cwd: tab.WorkspaceRoot,
IsolatedWorktree: floor.isolated,
+1 -1
View File
@@ -60,7 +60,7 @@ func (admission *tabTurnAdmission) abort() {
// beginTabTurn reserves one tab until its TurnDone fan-out completes.
func (a *App) beginTabTurn(tabID string, reclaim bool, submissionID ...string) (*tabTurnAdmission, control.SessionAPI, error) {
return a.beginRuntimeTurn(tabID, reclaim, false, submissionID...)
return a.beginRuntimeTurnChecked(tabID, reclaim, false, identifiedSubmissionCheck(submissionID), submissionID...)
}
func (a *App) beginRuntimeTurn(tabID string, reclaim, detached bool, submissionID ...string) (*tabTurnAdmission, control.SessionAPI, error) {
+5 -1
View File
@@ -52,6 +52,10 @@ type SubmissionAttachment struct {
// the caller must query the receipt before deciding whether a retry may run.
var ErrSubmissionNotAccepted = errors.New("submission not accepted")
// ErrSubmissionIdentityUnavailable marks a runtime with no durable store to
// record an identified submission in, such as a native legacy transcript.
var ErrSubmissionIdentityUnavailable = errors.New("durable submission identity unavailable")
type submissionIdentityState struct {
mu sync.Mutex
pending atomic.Pointer[pendingSubmissionAdmission]
@@ -279,7 +283,7 @@ func (c *Controller) acceptPreparedSubmission(ctx context.Context, candidate *Pr
store := c.sessionEventStore()
if req.ID != "" {
if store == nil {
return session.SubmissionReceipt{}, errors.Join(ErrSubmissionNotAccepted, errors.New("durable submission identity unavailable"))
return session.SubmissionReceipt{}, errors.Join(ErrSubmissionNotAccepted, ErrSubmissionIdentityUnavailable)
}
if c.Running() {
return session.SubmissionReceipt{}, errors.Join(ErrSubmissionNotAccepted, ErrTurnRunning)