Merge branch 'worktree/jobs-api-review-3052' into worktree/jobs-api-review-3127
# Conflicts: # packages/client/ui-jobs/src/client/JobListAction.tsx # packages/shell/bash-local/tests/executor.spec.ts
This commit is contained in:
commit
c4bc8b847a
1836 files changed
+37648
-15031
No files matched your search
@@ -127,6 +127,8 @@ export interface ISession {
|
||||
* turn-jump loader. Repeated calls while a jump is paging lower its shared
|
||||
* target and return the in-flight completion; `snapshot.loadingOlder` is
|
||||
* the busy signal for the whole jump.
|
||||
* Older pages publish together at completion, including successful pages
|
||||
* before a later failure; live events remain independently visible.
|
||||
* @param seq - durable event seq the window must reach (a turn's `turn/start` seq).
|
||||
* @returns completion once covered, exhausted, superseded, or failed soft.
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface SessionListEntry {
|
||||
title?: string
|
||||
updatedAt: number
|
||||
running: boolean
|
||||
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
|
||||
/** Summary blankness reconciled with `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). */
|
||||
blank: boolean
|
||||
parentSessionId?: SessionId
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client'
|
||||
import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
import type {
|
||||
SessionControlBaseline,
|
||||
@@ -223,6 +224,7 @@ export class SessionManager {
|
||||
/**
|
||||
* Lazy build: return the existing instance or construct one (no auto-open —
|
||||
* the reference allocator opens history after binding the scope).
|
||||
* New instances reconcile retained metadata before returning.
|
||||
* @param sessionId - the session to get.
|
||||
* @returns the resident instance.
|
||||
*/
|
||||
@@ -246,6 +248,9 @@ export class SessionManager {
|
||||
// durable history, even though child rows do not carry `blank`.
|
||||
session.handleBlank(false)
|
||||
session.handleRunning(child.activity === 'running')
|
||||
} else {
|
||||
// Retained metadata may have notified before this Session existed.
|
||||
session.handleBlank(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,9 +281,14 @@ export class SessionManager {
|
||||
let store = this.projectionStores.get(sessionId)
|
||||
if (store === undefined) {
|
||||
store = new ProjectionValueStore()
|
||||
// List rows project off store keys (title); any-key changes re-enter
|
||||
// the manager's own batched rebuild channel.
|
||||
store.subscribeAny(() => { this.notifier.markDirty() })
|
||||
const projections = store
|
||||
store.subscribeAny(() => {
|
||||
// Newer history or control metadata corrects a resident Session's stale list hint.
|
||||
if (projections.values().sessionListMetadata?.blank === false) {
|
||||
this.sessions.get(sessionId)?.handleBlank(false)
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
})
|
||||
this.projectionStores.set(sessionId, store)
|
||||
}
|
||||
return store
|
||||
@@ -405,7 +415,7 @@ export class SessionManager {
|
||||
this.summaries = mutations.reduce(applyMutation, baseline)
|
||||
this.listState = 'idle'
|
||||
this.listPhase = 'ready'
|
||||
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
|
||||
// Sessions reconcile list blank hints with their current metadata projection.
|
||||
for (const s of this.summaries) {
|
||||
const session = this.sessions.get(s.sessionId)
|
||||
if (session === undefined) continue
|
||||
@@ -575,6 +585,15 @@ export class SessionManager {
|
||||
return this.listSnapshotCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Read cached projection values for a Session that may exist only in a loaded subagent catalog.
|
||||
* @param sessionId - Session whose control or history baseline supplied projections.
|
||||
* @returns current values, or undefined before any projection store exists.
|
||||
*/
|
||||
projectionValues(sessionId: SessionId): Readonly<Partial<SessionProjectionMap>> | undefined {
|
||||
return this.projectionStores.get(sessionId)?.values()
|
||||
}
|
||||
|
||||
// ---- Live control and Host-event sinks ----
|
||||
|
||||
/**
|
||||
@@ -785,8 +804,12 @@ export class SessionManager {
|
||||
const projectionStore = this.projectionStores.get(summary.sessionId)
|
||||
const title = projectionStore?.get('title')
|
||||
const projectionValues = projectionStore?.values()
|
||||
const metadata = projectionValues?.sessionListMetadata
|
||||
return {
|
||||
...summary,
|
||||
// Cached list hints can precede a history opening or control update.
|
||||
blank: summary.blank && metadata?.blank !== false,
|
||||
updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0),
|
||||
...(typeof title === 'string' && title !== '' ? { title } : {}),
|
||||
...(projectionValues === undefined ? {} : { projectionValues }),
|
||||
}
|
||||
|
||||
@@ -37,10 +37,10 @@ export interface SessionSummary {
|
||||
/** Local ownership counts; Host metadata refreshes cannot overwrite them. */
|
||||
readonly retainedBy: SessionRetainInfo['retainedBy']
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
|
||||
* one targeting the same workspace. Filtering stays with the consumer: the
|
||||
* store carries every row, while the Workspace browser shows only the
|
||||
* selected blank entry.
|
||||
* Blankness from the Host summary reconciled with `sessionListMetadata`.
|
||||
* New Session reuses a blank one targeting the same workspace. Filtering
|
||||
* stays with the consumer: the store carries every row, while the Workspace
|
||||
* browser shows only the selected blank entry.
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
@@ -648,16 +648,25 @@ export class ClientSessions implements ISessions {
|
||||
for (const child of catalog.entries) {
|
||||
if (child.kind !== 'child') continue
|
||||
const childId = child.id
|
||||
const displayTitle = child.label ?? childId
|
||||
const summary = byId[childId]
|
||||
const projectionValues = summary?.projectionValues ?? this.manager.projectionValues(childId)
|
||||
const projectedTitle = projectionValues?.title
|
||||
const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : undefined
|
||||
const displayTitle = title ?? child.label ?? childId
|
||||
if (summary === undefined) {
|
||||
byId[childId] = {
|
||||
id: childId, displayTitle, parentId: parentId as SessionId,
|
||||
origin: 'subagent', running: child.activity === 'running', blank: false, updatedAt: 0,
|
||||
retainedBy: this.retentionSnapshot(childId).retainedBy,
|
||||
...(projectionValues === undefined ? {} : { projectionValues }),
|
||||
...(title === undefined ? {} : { title }),
|
||||
}
|
||||
} else if (summary.displayTitle !== displayTitle || summary.projectionValues !== projectionValues) {
|
||||
byId[childId] = {
|
||||
...summary,
|
||||
displayTitle,
|
||||
...(projectionValues === undefined ? {} : { projectionValues }),
|
||||
}
|
||||
} else if (summary.displayTitle !== displayTitle) {
|
||||
byId[childId] = { ...summary, displayTitle }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ export const PAGE_MESSAGES = 50
|
||||
/** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */
|
||||
export const JUMP_PAGE_MESSAGES = 200
|
||||
|
||||
interface PendingHistory {
|
||||
beforeSeq: SessionLogOffset
|
||||
hasMore: boolean
|
||||
readonly pages: (readonly SessionEventLikeEntry[])[]
|
||||
}
|
||||
|
||||
/** Manager-owned observers of a Session object's local state edges. */
|
||||
export interface SessionOptions {
|
||||
/** Catalog-discovered address selecting non-activating subagent transport. */
|
||||
@@ -97,6 +103,7 @@ export class Session implements SessionFace {
|
||||
private jumpTargetSeq: SessionSeq | null = null
|
||||
/** The running jump loop's completion, shared by retargeting callers. */
|
||||
private jumpPromise: Promise<void> | null = null
|
||||
private pendingHistory: PendingHistory | null = null
|
||||
private readonly stopObservingInbox: () => void
|
||||
private readonly assistantStream = new ClientAssistantStream()
|
||||
private running = false
|
||||
@@ -419,6 +426,14 @@ export class Session implements SessionFace {
|
||||
// target behind — only the loop's finally clears that field, and no
|
||||
// loop starts here.
|
||||
if (this.loadingOlder) return Promise.resolve()
|
||||
const events = this.events
|
||||
if (events === undefined) return Promise.resolve()
|
||||
const pending: PendingHistory = {
|
||||
beforeSeq: this.baseSeq,
|
||||
hasMore: this.hasMore,
|
||||
pages: [],
|
||||
}
|
||||
this.pendingHistory = pending
|
||||
this.jumpTargetSeq = seq
|
||||
this.loadingOlder = true
|
||||
this.notifier.markDirty()
|
||||
@@ -428,15 +443,13 @@ export class Session implements SessionFace {
|
||||
const generation = this.openGeneration
|
||||
this.jumpPromise = (async () => {
|
||||
try {
|
||||
while (this.hasMore && this.jumpTargetSeq !== null && this.baseSeq > this.jumpTargetSeq) {
|
||||
while (pending.hasMore && this.jumpTargetSeq !== null && pending.beforeSeq > this.jumpTargetSeq) {
|
||||
if (generation !== this.openGeneration) return
|
||||
const events = this.events
|
||||
if (events === undefined) return
|
||||
const before = this.baseSeq
|
||||
await events.prepend({ beforeSeq: this.baseSeq, maxMessages: JUMP_PAGE_MESSAGES })
|
||||
const before = pending.beforeSeq
|
||||
await events.prepend({ beforeSeq: before, maxMessages: JUMP_PAGE_MESSAGES })
|
||||
// No-progress guard: an empty or dropped page that still claims more
|
||||
// history must end the loop, not spin it.
|
||||
if (this.baseSeq >= before) return
|
||||
if (pending.beforeSeq >= before) return
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isRemoteFailure(error)) {
|
||||
@@ -445,7 +458,11 @@ export class Session implements SessionFace {
|
||||
} finally {
|
||||
this.jumpTargetSeq = null
|
||||
this.jumpPromise = null
|
||||
this.pendingHistory = null
|
||||
this.loadingOlder = false
|
||||
if (generation === this.openGeneration && pending.pages.length > 0) {
|
||||
this.prependWindow(pending.pages.reverse().flat(), pending.hasMore)
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
})()
|
||||
@@ -535,13 +552,12 @@ export class Session implements SessionFace {
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank-bit relay from the authoritative summary source (`session.list` and
|
||||
* `api-session/added`). Monotone: once any signal (local first send,
|
||||
* running flip, an earlier summary) cleared it, a stale true never
|
||||
* re-blanks.
|
||||
* @param blank - the summary's derived empty-log bit.
|
||||
* Relay list blankness without overriding a started conversation established
|
||||
* by the current projection, a local prompt, or running state.
|
||||
* @param blank - whether the list or projection reports an unstarted conversation.
|
||||
*/
|
||||
handleBlank(blank: boolean): void {
|
||||
blank = blank && this.projections.values().sessionListMetadata?.blank !== false
|
||||
if (blank === this.blankBit) return
|
||||
if (blank && (this.promptAttempted || this.running)) return
|
||||
this.blankBit = blank
|
||||
@@ -648,6 +664,11 @@ export class Session implements SessionFace {
|
||||
const visible = this.assistantStream.replace(entries, assistantStream)
|
||||
this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0)
|
||||
this.hasMore = hasMore
|
||||
if (this.pendingHistory !== null) {
|
||||
this.pendingHistory.beforeSeq = this.baseSeq
|
||||
this.pendingHistory.hasMore = hasMore
|
||||
this.pendingHistory.pages.length = 0
|
||||
}
|
||||
if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
this.eventSource.replace(visible, hasMore)
|
||||
@@ -684,6 +705,13 @@ export class Session implements SessionFace {
|
||||
|
||||
/** Prepend one stream-validated history page. */
|
||||
private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
|
||||
if (this.pendingHistory !== null) {
|
||||
const pending = this.pendingHistory
|
||||
pending.beforeSeq = entries[0] === undefined ? pending.beforeSeq : SessionLogOffset(entries[0].event.seq)
|
||||
pending.hasMore = hasMore
|
||||
pending.pages.push(entries)
|
||||
return
|
||||
}
|
||||
this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq)
|
||||
this.hasMore = hasMore
|
||||
this.eventSource.prepend(entries, hasMore)
|
||||
|
||||
@@ -521,6 +521,9 @@ export class SessionCommandController {
|
||||
|
||||
private rejectCreation(sessionId: SessionId, error: unknown): never {
|
||||
if (remoteErrorOf(error) !== undefined) throw error
|
||||
if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') {
|
||||
throw new RemoteError('session/writer-held', error.message, { sessionId })
|
||||
}
|
||||
if (error instanceof ApiSessionPresetConflict) {
|
||||
throw new RemoteError('agent-preset/conflict', error.message, {
|
||||
sessionId: error.sessionId,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
|
||||
|
||||
import { hostname } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-client-file-upload'
|
||||
import { canOpenNativePath, nativeFileManager, openNativePath, revealNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import { canOpenNativePath, nativeFileManager, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
@@ -90,6 +92,7 @@ export class SessionController extends TypertRemoteService {
|
||||
'agents',
|
||||
'attachments',
|
||||
'fileUploads',
|
||||
'fs',
|
||||
'llm',
|
||||
'sessions',
|
||||
'sessionProjections',
|
||||
@@ -135,7 +138,7 @@ export class SessionController extends TypertRemoteService {
|
||||
}, 'session-controller.promotions')
|
||||
this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
|
||||
this.listState = new ApiSessionList(ctx)
|
||||
this.openPath = internals.openPath ?? openNativePath
|
||||
this.openPath = internals.openPath ?? openNativeAssociatedPath
|
||||
this.revealPath = internals.revealPath ?? revealNativePath
|
||||
this.canOpenPath = internals.canOpenPath
|
||||
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
|
||||
@@ -284,11 +287,11 @@ export class SessionController extends TypertRemoteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one path prepared by a Session-aware caller on the Host desktop.
|
||||
* Verify one path through the composed filesystem and open it on the Host desktop.
|
||||
* @param request - path after best-effort Session workspace resolution.
|
||||
* @param signal - caller lifetime; abort terminates the native command.
|
||||
* @returns confirmation after the native opener accepts the path.
|
||||
* @throws RemoteError when the request is invalid, cancelled, or the opener fails.
|
||||
* @throws RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.
|
||||
*/
|
||||
@Remote('openWorkspacePath')
|
||||
async openWorkspacePath(
|
||||
@@ -304,6 +307,13 @@ export class SessionController extends TypertRemoteService {
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
const hostPath = resolve(request.path)
|
||||
const { fs } = this.ctx
|
||||
const mapped = fs.processPathFromHostPath(hostPath)
|
||||
if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) {
|
||||
throw new Error('Path has no verified Host path')
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (request.action === 'reveal') await this.revealPath(request.path, signal)
|
||||
else await this.openPath(request.path, signal)
|
||||
return { opened: true }
|
||||
|
||||
Reference in new issue
Block a user