From ff5f94d3fb298968a6ba7acb77e376f73e833287 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 22:40:00 +0800 Subject: [PATCH 001/141] refactor(client): import conversation node types from their declaring package Adding another client-face project to the client aggregate reshuffles the typert analyzer's batches, which exposed latent provenance violations in ui-chat and ui-approval: types consumed through ui-conversation's re-export path instead of their declaring package, and one slot-contract literal whose type the analyzer could not name. Import the types from @deepseek-ai/dsh-client-ui-conversation/client directly and give the approval interaction kind a named alias so the explicit annotation survives both the analyzer and lint. --- packages/client/ui-approval/src/client/contract/slots.ts | 5 ++++- .../client/ui-chat/src/client/chat/AssistantMarkdown.tsx | 2 +- .../client/ui-chat/src/client/chat/CompactionItem.tsx | 2 +- packages/client/ui-chat/src/client/chat/ContextBody.tsx | 5 +++-- .../ui-chat/src/client/chat/ContextInjectionRow.tsx | 2 +- packages/client/ui-chat/src/client/chat/MessageItem.tsx | 2 +- .../client/ui-chat/src/client/chat/turn-assistant.ts | 2 +- packages/client/ui-chat/src/client/contract/slots.ts | 4 ++-- .../ui-chat/src/client/conversation-nodes/assistant.ts | 3 +-- .../client/conversation-nodes/chat-snapshot-builder.ts | 9 +++------ .../ui-chat/src/client/conversation-nodes/command.ts | 3 +-- .../ui-chat/src/client/conversation-nodes/compaction.ts | 3 +-- .../ui-chat/src/client/conversation-nodes/fallback.ts | 5 +++-- .../ui-chat/src/client/conversation-nodes/message.ts | 5 +++-- .../ui-chat/src/client/conversation-nodes/partial.ts | 2 +- .../ui-chat/src/client/conversation-nodes/retry.ts | 3 +-- .../client/ui-chat/src/client/conversation-nodes/tool.ts | 3 +-- .../ui-chat/src/client/conversation-nodes/turn-error.ts | 3 +-- .../src/client/conversation-nodes/turn-max-tokens.ts | 3 +-- .../client/ui-chat/src/client/details/DetailsPanel.tsx | 3 ++- .../ui-chat/src/client/details/tool-node-reader.ts | 3 ++- .../ui-chat/src/client/model/conversation-context.ts | 5 +++-- .../client/ui-chat/src/client/model/tool-call-tree.ts | 4 +--- 23 files changed, 39 insertions(+), 42 deletions(-) diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts index f71a33008b..2980754542 100644 --- a/packages/client/ui-approval/src/client/contract/slots.ts +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -65,10 +65,13 @@ export type ApprovalDecision = 'allowed-once' | 'rejected' let nextApprovalKey = 0 +/** Domain discriminator literal carried by {@link PendingApproval.kind}. */ +export type ApprovalInteractionKind = 'approval' + /** One answerable Client presentation of a pending Host waterfall. */ export class PendingApproval { /** Domain discriminator used by Session pending-interaction consumers. */ - readonly kind = 'approval' as const + readonly kind: ApprovalInteractionKind = 'approval' /** Opaque render identity and one-shot remount axis. */ readonly key: string /** Tool requesting the decision. */ diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 8838725f75..a42758519a 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -1,9 +1,9 @@ +import type { AssistantBlock } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Fragment, memo, useMemo } from 'react' import type { ReactNode } from 'react' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' -import type { AssistantBlock } from '../contract/snapshot.ts' import { markdownLabels } from '../markdown-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' diff --git a/packages/client/ui-chat/src/client/chat/CompactionItem.tsx b/packages/client/ui-chat/src/client/chat/CompactionItem.tsx index b6108daf1a..da1346a582 100644 --- a/packages/client/ui-chat/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-chat/src/client/chat/CompactionItem.tsx @@ -1,3 +1,4 @@ +import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-ui-conversation/client' // A compaction marker does not replace shadowed transcript rows. It is // expandable only when the current window includes its cited summary. @@ -10,7 +11,6 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { markdownLabels } from '../markdown-labels.ts' -import type { CompactionSummaryNode } from '../contract/snapshot.ts' import css from './MessageItem.module.css' interface CompactionItemProps { diff --git a/packages/client/ui-chat/src/client/chat/ContextBody.tsx b/packages/client/ui-chat/src/client/chat/ContextBody.tsx index 6501ffda8f..3278f3d876 100644 --- a/packages/client/ui-chat/src/client/chat/ContextBody.tsx +++ b/packages/client/ui-chat/src/client/chat/ContextBody.tsx @@ -7,8 +7,9 @@ import type { ReactNode } from 'react' import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import type { ContextMessageNode } from '../contract/snapshot.ts' -import type { KnownContextForm } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ContextMessageNode, KnownContextForm, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import css from './ContextBody.module.css' /** Model-facing text stays bounded at the disclosure, not at the producer. */ diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx index 8be42c0f42..0930a25d56 100644 --- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx @@ -1,7 +1,7 @@ +import type { ContextMessageNode } from '@deepseek-ai/dsh-client-ui-conversation/client' import { useState } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' import { DisclosureRow, IconBrowseOutline16, ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ContextMessageNode } from '../contract/snapshot.ts' import { contextBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index c7368dc24d..fd0681bcbe 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,8 +1,8 @@ +import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '@deepseek-ai/dsh-client-ui-conversation/client' import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import { JsonBlock, MessageText, ReferenceIcon, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' -import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' diff --git a/packages/client/ui-chat/src/client/chat/turn-assistant.ts b/packages/client/ui-chat/src/client/chat/turn-assistant.ts index 90e075d79a..36c2ad2ec5 100644 --- a/packages/client/ui-chat/src/client/chat/turn-assistant.ts +++ b/packages/client/ui-chat/src/client/chat/turn-assistant.ts @@ -1,4 +1,4 @@ -import type { AssistantBlock } from '../contract/snapshot.ts' +import type { AssistantBlock } from '@deepseek-ai/dsh-client-ui-conversation/client' /** * Collect visible prose from one Assistant lifecycle. diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 5009781fd1..c035bd97af 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { ReactNode } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { - ConversationTurnDataMap, TurnLocation, + CommandNode, CompactionSummaryNode, ConversationTurnDataMap, ToolCallBlock, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory, @@ -14,7 +14,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget } from './store.ts' import type { ChatNode, ChatNodeKind } from './chat-nodes.ts' -import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts' +import type { ChatSnapshot } from './snapshot.ts' /** Selector hook over the current Conversation binding's Chat target. */ export type UseChat = SnapshotSelectorHook diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index f0f2b9be88..a2b5db79e2 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -1,11 +1,10 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { AssistantChatData } from '../contract/chat-nodes.ts' -import type { AssistantBlock, AssistantMessageNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' import { emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks, diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index f70e657014..2b306e2d99 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -1,14 +1,11 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder, - ConversationViewDefinition, + ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder, + ConversationViewDefinition, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' -import type { - ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ConversationNode, - LegacyConversationSlice, PartialAssistant, RunningToolCall, -} from '../contract/snapshot.ts' +import type { ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, LegacyConversationSlice } from '../contract/snapshot.ts' import { sessionRecallLabels } from './event-projection.ts' const EMPTY_KEYS: readonly string[] = [] diff --git a/packages/client/ui-chat/src/client/conversation-nodes/command.ts b/packages/client/ui-chat/src/client/conversation-nodes/command.ts index b4723d78fa..e21f50c5f5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/command.ts @@ -1,13 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/checkpoint' import type {} from '@deepseek-ai/dsh-compaction/types' import type {} from '@deepseek-ai/dsh-commands/types' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { ManualCompactionChatData } from '../contract/chat-nodes.ts' -import type { CommandNode, CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts index 5874c7bd61..9612f08741 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts @@ -1,9 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-compaction/types' -import type { CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { compactSource, compactSummary, updateCompactionState } from './command.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts index 356b46ed62..4675515dc3 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' -import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationNodeDefinition, UnknownSurfaceNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { UnknownSurfaceNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 9611a36cf8..548cf0dc12 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' -import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { ContextMessageNode, SteeringMessageNode, UserMessageNode } from '../contract/snapshot.ts' import type { InboxState } from './inbox.ts' import { chatNode } from './common.ts' import { contextForm, contextProvenance } from './event-projection.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts index ea52a3a565..04fc29dc13 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts @@ -1,5 +1,5 @@ +import type { AssistantBlock, PartialAssistant } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { StreamChunk } from '@deepseek-ai/dsh-llm/types' -import type { AssistantBlock, PartialAssistant } from '../contract/snapshot.ts' import { emptyAssistantBlock, toAssistantBlock } from './event-projection.ts' /** diff --git a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts index 5209e1dbc2..9f4f6bf9e9 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts @@ -1,10 +1,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationLocation, ConversationNodeDefinition, + ConversationLocation, ConversationNodeDefinition, ModelRetryNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { RetryChatData } from '../contract/chat-nodes.ts' -import type { ModelRetryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index b1f675c4e4..c96e66c434 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -1,11 +1,10 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type {} from '@deepseek-ai/dsh-tools/types' import type { ToolChatData } from '../contract/chat-nodes.ts' -import type { RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index e3b41095ce..941f23ac43 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -1,8 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { TurnErrorNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { displayFailure } from './event-projection.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts index 31508ab77c..746d00bc95 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts @@ -1,8 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { TurnMaxTokensNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/details/DetailsPanel.tsx b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx index 05c997e1fe..9a31274925 100644 --- a/packages/client/ui-chat/src/client/details/DetailsPanel.tsx +++ b/packages/client/ui-chat/src/client/details/DetailsPanel.tsx @@ -1,8 +1,9 @@ +import type { RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Fragment } from 'react' import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-store' import type { DetailsSlotProps } from '../contract/slots.ts' -import type { ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts' +import type { ChatSnapshot } from '../contract/snapshot.ts' import { findToolCall } from './tool-node-reader.ts' import css from './DetailsPanel.module.css' diff --git a/packages/client/ui-chat/src/client/details/tool-node-reader.ts b/packages/client/ui-chat/src/client/details/tool-node-reader.ts index 9e2488b68f..358286368e 100644 --- a/packages/client/ui-chat/src/client/details/tool-node-reader.ts +++ b/packages/client/ui-chat/src/client/details/tool-node-reader.ts @@ -1,5 +1,6 @@ +import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatNode } from '../contract/chat-nodes.ts' -import type { ChatNodeStore, ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts' +import type { ChatNodeStore, ChatSnapshot } from '../contract/snapshot.ts' function toolNode(node: ReturnType): ChatNode<'tool-call'> | undefined { return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined diff --git a/packages/client/ui-chat/src/client/model/conversation-context.ts b/packages/client/ui-chat/src/client/model/conversation-context.ts index 7f8116ffcc..442c455435 100644 --- a/packages/client/ui-chat/src/client/model/conversation-context.ts +++ b/packages/client/ui-chat/src/client/model/conversation-context.ts @@ -1,5 +1,6 @@ -import type { ConversationNode } from '../contract/snapshot.ts' -import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationNode, ConversationPromptSnapshot, +} from '@deepseek-ai/dsh-client-ui-conversation/client' /** Operation that started a new append-only model context. */ export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index ce311f5291..130d14bd65 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -1,8 +1,6 @@ +import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-tools/types' -import type { - ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, -} from '../contract/snapshot.ts' interface ProjectedBlock { source: ToolCallBlock From 4a793103394a334e38e81b5fbe4127bf22c43a2f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 22:40:14 +0800 Subject: [PATCH 002/141] feat(activity): optional live-output observation seam with a web viewer Add the activity capability seam: ctx.activities is a pure observation plane where long-running work (background bash/pwsh, workflow runs) opens a handle, appends transient output into a byte-offset ring buffer, and settles exactly once. Nothing model-visible changes: reads are non-consuming and lossy-by-design (absolute UTF-8 offsets survive eviction and report gaps), correlation carries {callId, jobId}, and visibility is owner-fenced like jobs. The seam is strictly optional. Producers tap it through ctx.get('activities') and contain every observation failure, so an unregistered or broken registry never affects the underlying job (proven by broken-registry tests). Ship the full path in one change: - @deepseek-ai/dsh-activity: Service Definition, pumpActivityOutput pull substrate, invariant companion; dsh-activity-local: ring-buffer provider with retention config and owner cleanup. - shell: ShellProcess.observed exposes non-consuming stdout/stderr readers; tool-bash/tool-pwsh mirror background jobs into activities. - tool-workflow: phase/log/agent lines mirrored as a workflow activity. - api-activity-controller: Typert Remote control (roster baseline+buckets) and observe (opened/output/status frames, coalesced and byte-capped) streams plus the client feed service with reference-counted observation. - ui-activity: session-header action rendering live output through TerminalBlock, which now shows output while a command is still running. Docs: activity subsystem page, architecture row, regenerated catalogs, and the Agent Note (2026-08-24-activity-observation-seam), all bilingual. A keyless web e2e replays a real run_in_background stream to ARIA goldens. --- ...-08-24-activity-observation-seam.i18n.yaml | 6 + .../2026-08-24-activity-observation-seam.md | 51 ++ ...2026-08-24-activity-observation-seam.zh.md | 51 ++ apps/cli/composition.md | 3 + apps/web/tests/live-activity-stream.e2e.ts | 134 ++++++ .../live-activity-stream/settled.expected.md | 8 + .../streaming.expected.md | 8 + apps/web/tsconfig.json | 1 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 1 + docs/architecture.zh.md | 1 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 17 +- docs/capability-seams.zh.md | 17 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 46 +- docs/config-catalog.zh.md | 44 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 1 + docs/subsystems/README.zh.md | 1 + docs/subsystems/activity.i18n.yaml | 6 + docs/subsystems/activity.md | 141 ++++++ docs/subsystems/activity.zh.md | 141 ++++++ docs/subsystems/shell.i18n.yaml | 4 +- docs/subsystems/shell.md | 7 + docs/subsystems/shell.zh.md | 7 + knip.json | 5 + packages/activity/README.i18n.yaml | 6 + packages/activity/README.md | 14 + packages/activity/README.zh.md | 14 + .../activity/activity-local/README.i18n.yaml | 6 + packages/activity/activity-local/README.md | 30 ++ packages/activity/activity-local/README.zh.md | 30 ++ packages/activity/activity-local/package.json | 55 +++ packages/activity/activity-local/src/index.ts | 438 ++++++++++++++++++ .../activity/activity-local/src/invariant.ts | 33 ++ .../activity-local/tests/activity.spec.ts | 413 +++++++++++++++++ .../tests/loader-composition.spec.ts | 60 +++ .../activity/activity-local/tsconfig.json | 33 ++ packages/activity/activity/README.i18n.yaml | 6 + packages/activity/activity/README.md | 32 ++ packages/activity/activity/README.zh.md | 32 ++ packages/activity/activity/package.json | 57 +++ packages/activity/activity/src/brand.ts | 28 ++ packages/activity/activity/src/index.ts | 138 ++++++ packages/activity/activity/src/invariant.ts | 62 +++ packages/activity/activity/src/pump.ts | 95 ++++ packages/activity/activity/src/types.ts | 179 +++++++ .../activity/activity/tests/invariant.spec.ts | 82 ++++ .../activity/activity/tests/service.spec.ts | 98 ++++ packages/activity/activity/tsconfig.json | 36 ++ .../api/activity-controller/README.i18n.yaml | 6 + packages/api/activity-controller/README.md | 30 ++ packages/api/activity-controller/README.zh.md | 30 ++ packages/api/activity-controller/package.json | 103 ++++ .../activity-controller/src/client/index.ts | 86 ++++ .../activity-controller/src/client/model.ts | 211 +++++++++ .../activity-controller/src/client/service.ts | 156 +++++++ packages/api/activity-controller/src/feed.ts | 184 ++++++++ packages/api/activity-controller/src/index.ts | 104 +++++ .../api/activity-controller/src/invariant.ts | 23 + .../api/activity-controller/src/observe.ts | 178 +++++++ packages/api/activity-controller/src/types.ts | 104 +++++ .../tests/activity-controller.host.spec.ts | 306 ++++++++++++ .../tests/client-apply.client.spec.ts | 113 +++++ .../tests/client-model.client.spec.ts | 168 +++++++ .../tests/client-service.client.spec.ts | 213 +++++++++ .../tests/controller.host.spec.ts | 102 ++++ .../activity-controller/tsconfig.client.json | 46 ++ .../activity-controller/tsconfig.host.json | 26 ++ .../api/activity-controller/tsconfig.json | 7 + .../api/activity-controller/tsdown.config.ts | 7 + packages/api/remotes/package.json | 2 + packages/api/remotes/src/client/index.ts | 5 +- packages/api/remotes/src/index.ts | 1 + packages/api/remotes/tsconfig.client.json | 3 + packages/api/remotes/tsconfig.host.json | 3 + packages/bundle/base/cordis.patch.yml | 5 + packages/bundle/base/package.json | 17 +- packages/bundle/web-app/cordis.patch.yml | 9 + packages/bundle/web-app/package.json | 42 +- packages/client/ui-activity/README.i18n.yaml | 6 + packages/client/ui-activity/README.md | 21 + packages/client/ui-activity/README.zh.md | 21 + packages/client/ui-activity/package.json | 77 +++ .../src/client/ActivityListAction.module.css | 155 +++++++ .../src/client/ActivityListAction.tsx | 250 ++++++++++ .../client/ui-activity/src/client/index.ts | 48 ++ .../client/ui-activity/src/client/locales.ts | 65 +++ .../client/ui-activity/src/css-modules.d.ts | 4 + packages/client/ui-activity/src/index.ts | 9 + packages/client/ui-activity/src/invariant.ts | 27 ++ .../activity-list-action.client.spec.tsx | 244 ++++++++++ .../tests/browser-plugin.client.spec.ts | 115 +++++ packages/client/ui-activity/tsconfig.json | 22 + packages/client/ui-activity/tsdown.config.ts | 6 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/TerminalBlock.tsx | 12 +- .../tests/terminal-block.client.spec.tsx | 21 +- .../src/client/slot-catalog.ts | 1 + .../extensions/tool-cordis/src/api-catalog.ts | 156 ++++++- packages/jobs/jobs/README.i18n.yaml | 4 +- packages/jobs/jobs/README.md | 2 +- packages/jobs/jobs/README.zh.md | 2 +- packages/shell/bash-local/src/index.ts | 1 + packages/shell/pwsh-local/src/index.ts | 1 + packages/shell/shell/README.i18n.yaml | 4 +- packages/shell/shell/README.md | 1 + packages/shell/shell/README.zh.md | 1 + packages/shell/shell/src/types.ts | 23 +- packages/shell/tool-bash/README.i18n.yaml | 4 +- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/package.json | 26 +- packages/shell/tool-bash/src/index.ts | 24 +- packages/shell/tool-bash/src/observe.ts | 72 +++ .../shell/tool-bash/tests/observe.spec.ts | 194 ++++++++ packages/shell/tool-bash/tsconfig.json | 3 + packages/shell/tool-pwsh/README.i18n.yaml | 4 +- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/shell/tool-pwsh/package.json | 24 +- packages/shell/tool-pwsh/src/index.ts | 32 +- packages/shell/tool-pwsh/src/observe.ts | 74 +++ .../shell/tool-pwsh/tests/observe.spec.ts | 154 ++++++ packages/shell/tool-pwsh/tsconfig.json | 3 + .../workflow/tool-workflow/README.i18n.yaml | 4 +- packages/workflow/tool-workflow/README.md | 2 +- packages/workflow/tool-workflow/README.zh.md | 2 +- packages/workflow/tool-workflow/package.json | 11 +- .../workflow/tool-workflow/src/activity.ts | 119 +++++ packages/workflow/tool-workflow/src/index.ts | 13 +- .../tests/activity-mirror.spec.ts | 106 +++++ packages/workflow/tool-workflow/tsconfig.json | 3 + pnpm-lock.yaml | 170 +++++++ python/sdk-runtime/package.json | 49 +- scripts/gen-cordis-catalog.ts | 13 + scripts/gen-doc-graphs.ts | 16 + .../verify-package-readme-model-experience.ts | 4 + tsconfig.base.json | 6 + tsconfig.client.json | 2 + tsconfig.host.json | 4 + 147 files changed, 7311 insertions(+), 138 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md create mode 100644 .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md create mode 100644 apps/web/tests/live-activity-stream.e2e.ts create mode 100644 apps/web/tests/snapshots/live-activity-stream/settled.expected.md create mode 100644 apps/web/tests/snapshots/live-activity-stream/streaming.expected.md create mode 100644 docs/subsystems/activity.i18n.yaml create mode 100644 docs/subsystems/activity.md create mode 100644 docs/subsystems/activity.zh.md create mode 100644 packages/activity/README.i18n.yaml create mode 100644 packages/activity/README.md create mode 100644 packages/activity/README.zh.md create mode 100644 packages/activity/activity-local/README.i18n.yaml create mode 100644 packages/activity/activity-local/README.md create mode 100644 packages/activity/activity-local/README.zh.md create mode 100644 packages/activity/activity-local/package.json create mode 100644 packages/activity/activity-local/src/index.ts create mode 100644 packages/activity/activity-local/src/invariant.ts create mode 100644 packages/activity/activity-local/tests/activity.spec.ts create mode 100644 packages/activity/activity-local/tests/loader-composition.spec.ts create mode 100644 packages/activity/activity-local/tsconfig.json create mode 100644 packages/activity/activity/README.i18n.yaml create mode 100644 packages/activity/activity/README.md create mode 100644 packages/activity/activity/README.zh.md create mode 100644 packages/activity/activity/package.json create mode 100644 packages/activity/activity/src/brand.ts create mode 100644 packages/activity/activity/src/index.ts create mode 100644 packages/activity/activity/src/invariant.ts create mode 100644 packages/activity/activity/src/pump.ts create mode 100644 packages/activity/activity/src/types.ts create mode 100644 packages/activity/activity/tests/invariant.spec.ts create mode 100644 packages/activity/activity/tests/service.spec.ts create mode 100644 packages/activity/activity/tsconfig.json create mode 100644 packages/api/activity-controller/README.i18n.yaml create mode 100644 packages/api/activity-controller/README.md create mode 100644 packages/api/activity-controller/README.zh.md create mode 100644 packages/api/activity-controller/package.json create mode 100644 packages/api/activity-controller/src/client/index.ts create mode 100644 packages/api/activity-controller/src/client/model.ts create mode 100644 packages/api/activity-controller/src/client/service.ts create mode 100644 packages/api/activity-controller/src/feed.ts create mode 100644 packages/api/activity-controller/src/index.ts create mode 100644 packages/api/activity-controller/src/invariant.ts create mode 100644 packages/api/activity-controller/src/observe.ts create mode 100644 packages/api/activity-controller/src/types.ts create mode 100644 packages/api/activity-controller/tests/activity-controller.host.spec.ts create mode 100644 packages/api/activity-controller/tests/client-apply.client.spec.ts create mode 100644 packages/api/activity-controller/tests/client-model.client.spec.ts create mode 100644 packages/api/activity-controller/tests/client-service.client.spec.ts create mode 100644 packages/api/activity-controller/tests/controller.host.spec.ts create mode 100644 packages/api/activity-controller/tsconfig.client.json create mode 100644 packages/api/activity-controller/tsconfig.host.json create mode 100644 packages/api/activity-controller/tsconfig.json create mode 100644 packages/api/activity-controller/tsdown.config.ts create mode 100644 packages/client/ui-activity/README.i18n.yaml create mode 100644 packages/client/ui-activity/README.md create mode 100644 packages/client/ui-activity/README.zh.md create mode 100644 packages/client/ui-activity/package.json create mode 100644 packages/client/ui-activity/src/client/ActivityListAction.module.css create mode 100644 packages/client/ui-activity/src/client/ActivityListAction.tsx create mode 100644 packages/client/ui-activity/src/client/index.ts create mode 100644 packages/client/ui-activity/src/client/locales.ts create mode 100644 packages/client/ui-activity/src/css-modules.d.ts create mode 100644 packages/client/ui-activity/src/index.ts create mode 100644 packages/client/ui-activity/src/invariant.ts create mode 100644 packages/client/ui-activity/tests/activity-list-action.client.spec.tsx create mode 100644 packages/client/ui-activity/tests/browser-plugin.client.spec.ts create mode 100644 packages/client/ui-activity/tsconfig.json create mode 100644 packages/client/ui-activity/tsdown.config.ts create mode 100644 packages/shell/tool-bash/src/observe.ts create mode 100644 packages/shell/tool-bash/tests/observe.spec.ts create mode 100644 packages/shell/tool-pwsh/src/observe.ts create mode 100644 packages/shell/tool-pwsh/tests/observe.spec.ts create mode 100644 packages/workflow/tool-workflow/src/activity.ts create mode 100644 packages/workflow/tool-workflow/tests/activity-mirror.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml new file mode 100644 index 0000000000..07186d7a2c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md +2026-08-24-activity-observation-seam.md: da356b3b871a45958dc5a5214ccff8b49c0c193f +2026-08-24-activity-observation-seam.zh.md: 1c0c87d23a08d41882f00fd8f729f794d09169b1 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md new file mode 100644 index 0000000000..da356b3b87 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md @@ -0,0 +1,51 @@ +# Agent Note: The activity observation seam (`ctx.activities`) and live output streaming to the Web client + +Status: implemented + +English | [中文](2026-08-24-activity-observation-seam.zh.md) + +## Problem + +Every incremental reader in the shell, terminal, and jobs layers is single-consumer and consuming: `ShellProcess.readOutput()`, `TerminalSendOperation.readOutput()`, and `JobRegistry.read()` all destroy the delta they return, because the owning model is the intended reader. The [web background-job display](2026-08-08-web-background-job-display.md) shipped the roster on those terms — its carrier is test-pinned to never call `ctx.jobs.read()`, since a browser read would silently steal bytes the model's `job_output` would never see — and explicitly deferred the output phase to "a separate non-consuming observation API". So a human watching the Web client saw that a background build was running but never a line of its output, and the `workflow/log` / `workflow/phase` events scripts are told to narrate with had no consumer at all. + +The substrate below bash already had the right shape: `SubprocessOutputReader.readFrom(fromByte)` is offset-based and explicitly non-consuming, and `bash-local` narrows it to a single cursor pair at the shell seam. + +## Decision + +A new capability seam, `packages/activity/`, is the non-consuming observation plane. An **activity** is one observable unit of long-running work: an append-only bounded output stream plus live status. The seam is pure observation — it starts nothing, cancels nothing, and is invisible to the model. Model-visible facts stay exactly where they were (tool results, `ctx.jobs` cursors), so the "model-visible ⟺ logged" invariant is untouched and a composition without the seam loses only live observation. + +- **`@deepseek-ai/dsh-activity` (Service Definition)** — abstract `ActivityRegistry` on `ctx.activities`: `open(spec) → ActivityHandle { append, updateDetail, end }`, fenced `get`/`list`, non-consuming `read(id, from)` over absolute UTF-8 byte offsets, owner-relative `onActivitiesChanged` (roster) and `onOutput` (advancement signal, id only). `ActivityCorrelation { callId?, jobId? }` links a row to its tool call and job. `pumpActivityOutput` is the shared pull-substrate pump. +- **`@deepseek-ai/dsh-activity-local` (Service Provider)** — in-memory chunk ring per activity. Offsets stay absolute across eviction (`outputEarliest` marks the oldest retained byte; a read below it is `lossy`, never an error); a single over-cap chunk keeps its UTF-8-safe tail with `gapBefore`. Config `retainBytes` (256 KiB) live, trimmed to `settledRetainBytes` (16 KiB) at settlement. Records outlive producer fibers; owner disposal force-ends and removes; `end` is first-wins and post-end writes log-and-drop so a producer's trailing flush cannot break its own teardown. +- **`@deepseek-ai/dsh-api-activity-controller`** — the wire, in the workspace-controller shape: `activity.control` streams one roster baseline then whole-bucket replacement frames per owner session (the jobs-frame self-healing semantics), and `activity.observe({ activityId, from? })` streams one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget), then the terminal `status` on the same stream before closing — so settlement can never race a still-open output channel. Reconnects resume from the last frame's `next`. The client half installs `ctx.activityFeed` (roster mirror plus reference-counted observation streams with a bounded render tail). +- **`@deepseek-ai/dsh-client-ui-activity`** — one session-header action beside the job list; expanding a row opens its observation stream into a `TerminalBlock` panel, collapsing closes it, so output only flows while someone is watching. `TerminalBlock` itself gained live rendering: a running block with supplied `output` shows the text under the running state instead of the historical prompt-only frame. + +Producers mirror best-effort behind `ctx.get('activities')`, never a declared inject, and every observation failure is logged and swallowed — the seam is strictly optional and can never break the work it observes: + +- `ShellProcess` gained optional `observed` — the substrate's non-consuming offset readers re-exposed (bash-local and pwsh-local return `handle.collected`; the e2b subprocess provider satisfies the same contract). `dsh-tool-bash` / `dsh-tool-pwsh` open a correlated activity after `jobs.start()` commits and pump `observed` at `activityPollMs` (default 150 ms). +- `dsh-tool-workflow` mirrors each recorded top-level run as a push-mode `workflow` activity, giving the previously unconsumed `workflow/phase` / `workflow/log` events their consumer. + +### Why not durable session events, the control stream, or the jobs seam + +Peer products converge on the same split (Codex marks every exec-output delta "Transient, non-durable" in its rollout policy; Claude Code persists a file path, never deltas; Kimi's envelope has a first-class `volatile: true`; OpenCode's V2 corrected V1's per-chunk durable part updates to "stream fragments are live-only"). In this repository the durable route is also concretely bad: chunk-run packing is hardcoded to the three `assistant/chunk` shapes so a new output event gets zero compression, each event is one uncoalesced WebSocket frame, and reconnect gap-repair re-reads the window — and the job-display note already rejected it ("spill exists precisely so oversized tool output stays out of the log"). The session control stream's whole-snapshot frames are self-healing for rosters but quadratic for streams. Extending the jobs seam would cover only jobs — not workflow narration, not a future foreground stream — and would re-litigate the settled "producers own their buffers" decision; the activity plane instead recovers the substrate's existing offsets. + +### Two cursor regimes, kept separate + +The model keeps its consuming cursors (`job_output` semantics unchanged, zero prompt or tool changes anywhere in this work); observers get absolute offsets any number of readers can hold. This mirrors the strongest peer pattern (Codex: destructive drain for the model, seq-cursor reads for infrastructure) and is what makes the wire path provably non-stealing. + +## Alternatives considered + +**Durable `activity/output` session events with a conversation node.** Replay for free, but the log-growth math above, plus retention pressure on every follower, killed it; the transcript's durable story remains the producers' tool results. + +**Extending `SessionControlFrame` with output.** One fewer stream, but whole-snapshot semantics resend the accumulated buffer per chunk — O(n²) on exactly the data that grows. + +**A signal frame plus RPC pull.** The subagent-catalog shape; rejected for the split-authority apparatus the job-display note documents, and it fails worst at settlement (running row, dead stream). + +**Observation inside the jobs seam.** One integration point, but only for jobs; foreground runs, terminals, and workflow narration would each need another mechanism, and the registry would grow a second buffer beside the producers' own. + +## Testing + +Unit suites pin the registry (offsets, eviction, UTF-8-safe tail trims, lossy reads, first-wins settlement, owner cleanup, scoped delivery, listener containment, HMR disposal, Loader composition with row config), the pump (cadence, per-source cursors, lossy `gapBefore`, settlement drain, timer hygiene), the controller (baseline/bucket frames, anchor/output/status ordering, resume, frame splitting, fenced owned reads, abort), the client model and reference-counted streams, and both producer taps (real subprocess output for bash including the never-steals-the-model's-cursor assertion; scripted readers for pwsh; event-driven mirror for workflow, including the throwing-registry containment the optionality promise requires). The keyless web e2e drives a real `run_in_background` bash through the full composition and pins ARIA goldens for the streaming panel while the command still runs and for the settled panel after a registry kill. + +## Consequences + +The Web client now shows live output for background bash/pwsh and workflow narration with zero model-facing change, and any plugin can join with three calls (`open`/`append`/`end`) against an optional service. Bought at the cost of one more seam family and a per-producer tap; the roster duplicates a background job's row across two header lists (accepted, as the subagent catalog precedent was). Deferred, recorded on the package READMEs: foreground streaming (needs a shell-seam run/observe variant), terminal `pty-send` observation (needs a non-consuming face on terminal buffers), a human kill control (blocked on the jobs `reported` contract), SDK/ACP consumers, and durable replay after restart. diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md new file mode 100644 index 0000000000..1c0c87d23a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md @@ -0,0 +1,51 @@ +# Agent Note:activity 观察 seam(`ctx.activities`)与 Web 客户端实时输出流 + +状态:implemented + +[English](2026-08-24-activity-observation-seam.md) | 中文 + +## 问题 + +shell、terminal 与 jobs 各层的增量读取器全都是单读者且消费型的:`ShellProcess.readOutput()`、`TerminalSendOperation.readOutput()` 与 `JobRegistry.read()` 返回即销毁增量,因为预期读者就是持有模型。[Web 后台任务显示](2026-08-08-web-background-job-display.zh.md)在这一前提下交付了 roster——其载体被测试钉死绝不调用 `ctx.jobs.read()`,否则浏览器读取会无声偷走模型 `job_output` 永远看不到的字节——并把输出阶段明确推迟给"一个独立的非消费观察 API"。于是人在 Web 客户端只能看到后台构建"在跑",看不到一行输出;脚本被告知用来叙述进度的 `workflow/log` / `workflow/phase` 事件更是全无消费者。 + +bash 之下的底座其实早有正确形态:`SubprocessOutputReader.readFrom(fromByte)` 是按 offset、明确非消费的,`bash-local` 在 shell seam 把它窄化成了单游标对。 + +## 决策 + +新建能力 seam `packages/activity/`,作为非消费观察面。**activity** 是一个可观察的长时工作单元:一条 append-only 的有界输出流加实时状态。该 seam 是纯观察——不启动、不取消任何工作,对模型不可见。模型可见事实原地不动(工具结果、`ctx.jobs` 游标),"模型可见 ⟺ 已记录"不变量不受影响;不装该 seam 的组合只失去实时观察。 + +- **`@deepseek-ai/dsh-activity`(Service Definition)**——`ctx.activities` 上的抽象 `ActivityRegistry`:`open(spec) → ActivityHandle { append, updateDetail, end }`、有栅栏的 `get`/`list`、按绝对 UTF-8 字节 offset 的非消费 `read(id, from)`、owner 相对投递的 `onActivitiesChanged`(roster)与 `onOutput`(推进信号,只带 id)。`ActivityCorrelation { callId?, jobId? }` 把行关联到工具调用与 job。`pumpActivityOutput` 是 pull 型底座的共享泵。 +- **`@deepseek-ai/dsh-activity-local`(Service Provider)**——每个 activity 一个内存 chunk 环。offset 跨淘汰稳定(`outputEarliest` 指明最老保留字节;低于它的读取是 `lossy` 而非错误);单个超上限 chunk 保留 UTF-8 安全尾部并带 `gapBefore`。配置 `retainBytes`(256 KiB)存活,结算时裁到 `settledRetainBytes`(16 KiB)。记录存续期长于生产者 fiber;owner 销毁强制终结并移除;`end` first-wins,end 后写入记日志后丢弃,生产者的收尾 flush 砸不掉自己的 teardown。 +- **`@deepseek-ai/dsh-api-activity-controller`**——wire 层,取 workspace-controller 的形态:`activity.control` 流出一份 roster baseline 加按 owner 会话的整桶替换帧(jobs 帧的自愈语义);`activity.observe({ activityId, from? })` 流出一帧 `opened` 锚点、合并的 `output` 帧(`flushMs` 窗口、`maxFrameBytes` 软预算)、再在同一条流上发终态 `status` 后关闭——结算永远不会与仍开着的输出通道竞态。重连以上一帧的 `next` 续传。客户端半区安装 `ctx.activityFeed`(roster 镜像 + 按引用计数的观察流与有界渲染尾巴)。 +- **`@deepseek-ai/dsh-client-ui-activity`**——job 列表旁的一个会话头部 action;展开某行即把其观察流打开进 `TerminalBlock` 面板,收起即关——只有有人在看时输出才流动。`TerminalBlock` 本身获得实时渲染能力:running 且提供了 `output` 的块在 running 状态下显示文本,而非历史上的仅提示行画面。 + +生产者一律经 `ctx.get('activities')` 尽力镜像,绝不声明 inject,一切观察失败只记日志并吞掉——该 seam 严格可选,永远不能破坏它所观察的工作: + +- `ShellProcess` 新增可选 `observed`——底座的非消费 offset 读取器复出(bash-local 与 pwsh-local 返回 `handle.collected`;e2b subprocess provider 满足同一契约)。`dsh-tool-bash` / `dsh-tool-pwsh` 在 `jobs.start()` 提交后打开带关联的 activity,并以 `activityPollMs`(默认 150 ms)泵取 `observed`。 +- `dsh-tool-workflow` 把每个被记录的顶层运行镜像为 push 形态的 `workflow` activity,让此前无人消费的 `workflow/phase` / `workflow/log` 事件有了消费者。 + +### 为何不用持久会话事件、control 流或 jobs seam + +同行产品收敛于同一切分(Codex 在 rollout policy 里把所有 exec 输出 delta 标为 "Transient, non-durable";Claude Code 持久化文件路径而非 delta;Kimi 的信封有一等的 `volatile: true`;OpenCode V2 把 V1 逐 chunk 持久化 part 更新纠正为 "stream fragments are live-only")。在本仓库持久路线还有具体的坏处:chunk-run 打包只认三种 `assistant/chunk` 形态,新输出事件零压缩;每事件一条未合并的 WebSocket 帧;重连缺口修复要重读窗口——且 job 显示 note 已否决过它("spill 的存在就是为了让超大工具输出不进日志")。session control 流的整值帧对 roster 自愈、对流则是二次方开销。扩 jobs seam 只覆盖 jobs——workflow 叙述与未来的前台流都覆盖不到——还要重翻已定案的"生产者拥有自己的缓冲";activity 面转而复用底座既有的 offset。 + +### 两套游标制,各归其位 + +模型保留消费游标(`job_output` 语义不变,本次工作零 prompt/工具改动);观察者拿任意多读者可共持的绝对 offset。这正是最强的同行范式(Codex:模型用破坏性 drain,基础设施用 seq 游标读取),也是 wire 路径可证明"不偷字节"的原因。 + +## 曾考虑的替代方案 + +**持久 `activity/output` 会话事件 + conversation node。** 回放免费,但上述日志膨胀账加上对每个 follower 的保留压力否掉了它;transcript 侧的持久故事仍是生产者的工具结果。 + +**给 `SessionControlFrame` 加输出。** 少一条流,但整值语义会按 chunk 重发累计缓冲——恰好在会增长的数据上 O(n²)。 + +**信号帧 + RPC 拉取。** subagent catalog 的形态;因 job 显示 note 记录的权威割裂机制而否决,且它在结算时刻失效得最难看(行还在跑、流已死)。 + +**在 jobs seam 内做观察。** 一个集成点,但只对 jobs;前台运行、terminal 与 workflow 叙述各需另一套机制,注册表还得在生产者自己的缓冲旁再养一份。 + +## 测试 + +单测钉住注册表(offset、淘汰、UTF-8 安全裁尾、lossy 读取、first-wins 结算、owner 清理、scope 投递、监听容错、HMR 可逆、带行配置的 Loader 组合)、泵(节奏、每源游标、lossy `gapBefore`、结算排干、定时器卫生)、controller(baseline/桶帧、锚点/输出/状态排序、续传、帧切分、有栅栏的有主读取、abort)、客户端模型与引用计数流,以及两类生产者接入(bash 用真实子进程输出,含"绝不偷模型游标"断言;pwsh 用脚本化读取器;workflow 用事件驱动镜像,含可选性承诺要求的 throwing-registry 容错)。无钥 web e2e 经完整组合驱动真实 `run_in_background` bash,钉住命令仍在运行时流式面板与注册表 kill 后已结算面板的 ARIA golden。 + +## 后果 + +Web 客户端在零模型面改动下获得后台 bash/pwsh 实时输出与 workflow 叙述;任何插件对着一个可选服务用三个调用(`open`/`append`/`end`)即可接入。代价是多一个 seam 家族与每生产者一个接入点;后台 job 的行会在两个头部列表重复出现(沿用 subagent catalog 的既有取舍)。暂缓项记录在各包 README:前台流式(需 shell seam 的 run/observe 变体)、terminal `pty-send` 观察(需给 terminal 缓冲加非消费面)、人工 kill 控件(被 jobs `reported` 契约阻塞)、SDK/ACP 消费端、重启后的持久回放。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 9ec9a9a22d..31548a2d43 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -40,6 +40,8 @@ flowchart LR cfg --> plugin_dsh_base_agent_default_model plugin_dsh_base_jobs["jobs
@deepseek-ai/dsh-jobs-local"] cfg --> plugin_dsh_base_jobs + plugin_dsh_base_activity["activity
@deepseek-ai/dsh-activity-local"] + cfg --> plugin_dsh_base_activity plugin_dsh_base_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] cfg --> plugin_dsh_base_llm_retry plugin_dsh_base_settings["settings
@deepseek-ai/dsh-settings-file"] @@ -190,6 +192,7 @@ flowchart LR | `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | +| `activity` | `@deepseek-ai/dsh-activity-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | | `settings` | `@deepseek-ai/dsh-settings-file` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | diff --git a/apps/web/tests/live-activity-stream.e2e.ts b/apps/web/tests/live-activity-stream.e2e.ts new file mode 100644 index 0000000000..750e1697f3 --- /dev/null +++ b/apps/web/tests/live-activity-stream.e2e.ts @@ -0,0 +1,134 @@ +// Session-header live activities driven by a real background bash run: the +// roster row arrives over the activity control stream, expanding it opens the +// observation stream, and the panel shows the process's real output while it +// is still running. No model call is involved. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { JobId } from '@deepseek-ai/dsh-jobs' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-activity-stream', import.meta.url)) +const STREAMING_EXPECTED = join(SNAPSHOT_DIR, 'streaming.expected.md') +const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'live-activity-stream-web-e2e' +// One fixed line of early output, then silence long enough that the streaming +// assertions never race the process exiting; the test kills it explicitly. +const COMMAND = "printf 'streamed-%s\\n' marker-line; sleep 45" + +/** + * Wait for opening a session to publish its live Agent. + * @param scaffold - the booted web scaffold. + * @param sessionId - the opened session's identity. + * @returns the registered Agent instance. + */ +async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { + const deadline = Date.now() + 30_000 + for (;;) { + const found = scaffold.ctx.agents.get(sessionId) + if (found !== undefined) return found + if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`) + await new Promise(resolve => setTimeout(resolve, 100)) + } +} + +describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + let jobId: JobId + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + // Opening the session drives the Host's ordinary Agent resolution; the + // activity owner must be that exact live instance, never a second one. + agent = await liveAgent(scaffold, SessionId(SEED_ID)) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('streams a running background command\'s real output into the expanded panel', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-streaming')) + const trigger = page.getByRole('button', { name: '1 live activity' }) + expect(await trigger.count()).toBe(0) + + const started = await scaffold.ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('live-activity-stream-e2e'), + name: 'bash', + arguments: { command: COMMAND, description: 'Emit one live line then hold', run_in_background: true }, + agent, + }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /\bbash-\d+\b/.exec(reported) + if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) + jobId = JobId(matched[0]) + + // The roster row reaches the header over the activity control stream. + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + + // Expanding the row opens the observation stream; the panel then renders + // the command's real stdout while the process is still running. + const expand = page.getByRole('button', { name: /Show live output/ }) + await expand.waitFor({ timeout: 10_000 }) + await expand.click() + await page.getByText('streamed-marker-line').waitFor({ timeout: 15_000 }) + + const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STREAMING_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('settles the open panel when the job is killed, keeping the streamed tail', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-settled')) + expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') + + // Settlement arrives on the observation stream itself, so the open panel + // flips without any further interaction; the roster trigger follows. + const idle = page.getByRole('button', { name: '1 activity' }) + await idle.waitFor({ timeout: 20_000 }) + await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) + + const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['settled.expected.md', 'streaming.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md new file mode 100644 index 0000000000..7ffec602cb --- /dev/null +++ b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md @@ -0,0 +1,8 @@ +- list "Live activities": + - listitem: + - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: + - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM" + - img + - text: done $ printf 'streamed-%s\n' marker-line; sleep 45 + - button "Copy" + - text: streamed-marker-line diff --git a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md new file mode 100644 index 0000000000..fd536ef430 --- /dev/null +++ b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md @@ -0,0 +1,8 @@ +- list "Live activities": + - listitem: + - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: + - text: bash printf 'streamed-%s\n' marker-line; sleep 45 running + - img + - text: running $ printf 'streamed-%s\n' marker-line; sleep 45 + - button "Copy" + - text: streamed-marker-line diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 386615f059..9ea5d0c61e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -90,6 +90,7 @@ "tests/subagent-interrupt-ui.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", "tests/background-job-list.e2e.ts", + "tests/live-activity-stream.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", "tests/turn-tail-actions.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 82e57fd2f7..a7e83a6223 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: add615c252948adab8db7dec7059f94b8f45e52c -architecture.zh.md: 48baf14d6a29e5ef77e6e47f4d9fa9adc4e9e748 +architecture.md: cfa1dee639ebc4ae60db13d11d246c2ffa103cd0 +architecture.zh.md: 0139f575a2721a429ac6b241638c031c68f8530f diff --git a/docs/architecture.md b/docs/architecture.md index add615c252..cfa1dee639 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -127,6 +127,7 @@ New behavior attaches to a documented extension point. Changing the loop itself | Add persistent terminal execution | register a `ctx.terminals` backend plus `dsh-tool-terminal` | | Add a human command | register on `ctx.commands`; it dispatches without a model turn | | Add background work | register on `ctx.jobs`; `job_*` tools collect or stop it | +| Stream live output to observers | open a `ctx.activities` handle; the Web client renders it | | Start a Session from an external webhook | register a trusted rule on `ctx.webhookRuntime` and mount a provider adapter | | Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 48baf14d6a..0139f575a2 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -131,6 +131,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` | | 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 | | 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 | +| 向观察者流式输出 | 打开一个 `ctx.activities` 句柄;Web 客户端负责渲染 | | 从外部 webhook 启动 Session | 在 `ctx.webhookRuntime` 上注册可信规则,并挂载提供方适配器 | | 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 | | 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index d7e1c70e5a..c1a409d2e8 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 0994b186f7daa6afbff0f1484216e55fc79170ff -capability-seams.zh.md: 48aa0a5353bba8d56f63abfcfc4cd2c601569cb6 +capability-seams.md: de9b8e1eb68dd7bf110609044dbf96a93a59adc5 +capability-seams.zh.md: 0b8e93fd3f0b08770652ca735966649b35f98330 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 0994b186f7..de9b8e1eb6 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -40,6 +40,8 @@ flowchart LR pkg_apiproxy["apiproxy"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] + pkg_api_activity_controller["api-activity-controller"] + svc_activityController["ctx.activityController
Host Activity Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -178,6 +180,11 @@ flowchart LR svc_jobs["ctx.jobs
Background job registry"] pkg_jobs_local["jobs-local"] pkg_tool_jobs["tool-jobs"] + pkg_activity["activity"] + svc_activities["ctx.activities
Live activity observation registry"] + pkg_activity_local["activity-local"] + pkg_tool_workflow["tool-workflow"] + pkg_activity_controller["activity-controller"] pkg_web["web"] svc_web["ctx.web
Web access provider registry"] pkg_web_search_exa["web-search-exa"] @@ -201,7 +208,6 @@ flowchart LR pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
Workflow script engine"] pkg_workflow_worker_thread["workflow-worker-thread"] - pkg_tool_workflow["tool-workflow"] pkg_webhook["webhook"] svc_webhookRuntime["ctx.webhookRuntime
Webhook rule runtime"] pkg_webhook_github["webhook-github"] @@ -214,11 +220,14 @@ flowchart LR svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] pkg_acp --> svc_approval + pkg_activity --> svc_activities + pkg_activity_local --> svc_activities pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_agent_team --> svc_agentTeams + pkg_api_activity_controller --> svc_activityController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_workspace_controller --> svc_workspaceController @@ -325,6 +334,10 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + svc_activities --> pkg_activity_controller + svc_activities --> pkg_tool_bash + svc_activities --> pkg_tool_pwsh + svc_activities --> pkg_tool_workflow svc_agentDefaultModel --> pkg_headless svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo @@ -456,6 +469,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | `apiproxy` | - | Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | +| `ctx.activityController` | `core` | [`api-activity-controller`](../packages/api/activity-controller) | - | - | - | Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | @@ -501,6 +515,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.agentTeams` | `core` | `agent-team` | - | `tool-agent-team` | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes the scoped model policy and controls. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | +| `ctx.activities` | `seam` | [`activity`](../packages/activity/activity) | [`activity-local`](../packages/activity/activity-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-workflow`](../packages/workflow/tool-workflow), `activity-controller` | - | Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 48aa0a5353..0b8e93fd3f 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -42,6 +42,8 @@ flowchart LR pkg_apiproxy["apiproxy"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] + pkg_api_activity_controller["api-activity-controller"] + svc_activityController["ctx.activityController
Host Activity Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -180,6 +182,11 @@ flowchart LR svc_jobs["ctx.jobs
Background job registry"] pkg_jobs_local["jobs-local"] pkg_tool_jobs["tool-jobs"] + pkg_activity["activity"] + svc_activities["ctx.activities
Live activity observation registry"] + pkg_activity_local["activity-local"] + pkg_tool_workflow["tool-workflow"] + pkg_activity_controller["activity-controller"] pkg_web["web"] svc_web["ctx.web
Web access provider registry"] pkg_web_search_exa["web-search-exa"] @@ -203,7 +210,6 @@ flowchart LR pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
Workflow script engine"] pkg_workflow_worker_thread["workflow-worker-thread"] - pkg_tool_workflow["tool-workflow"] pkg_webhook["webhook"] svc_webhookRuntime["ctx.webhookRuntime
Webhook rule runtime"] pkg_webhook_github["webhook-github"] @@ -216,11 +222,14 @@ flowchart LR svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] pkg_acp --> svc_approval + pkg_activity --> svc_activities + pkg_activity_local --> svc_activities pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_agent_team --> svc_agentTeams + pkg_api_activity_controller --> svc_activityController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_workspace_controller --> svc_workspaceController @@ -327,6 +336,10 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + svc_activities --> pkg_activity_controller + svc_activities --> pkg_tool_bash + svc_activities --> pkg_tool_pwsh + svc_activities --> pkg_tool_workflow svc_agentDefaultModel --> pkg_headless svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo @@ -458,6 +471,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | `apiproxy` | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态与 Agent 激活策略;apiProxy 在需要 Session 上下文的领域中复用其检查和 Agent 解析操作。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | +| `ctx.activityController` | `core` | [`api-activity-controller`](../packages/api/activity-controller) | - | - | - | 通过生成的 Remote namespace 负责实时活动 roster 流与逐 activity 的非消费输出观察。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | @@ -503,6 +517,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.agentTeams` | `core` | `agent-team` | - | `tool-agent-team` | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供作用域化模型策略和控制工具。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | +| `ctx.activities` | `seam` | [`activity`](../packages/activity/activity) | [`activity-local`](../packages/activity/activity-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-workflow`](../packages/workflow/tool-workflow), `activity-controller` | - | 生产方把实时输出与状态镜像进非消费、按 offset 寻址的流;activity-controller 把它们送达浏览器,模型侧路径(jobs 游标、工具结果)不受影响。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9ba399370d..6c2e824a55 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 6494070f6204ce6130e22d6f71ddbfd18fdc69dd -config-catalog.zh.md: da19d854ca520425a799cb699238a7dca05a90da +config-catalog.md: 0bf883bbfde80377602be90bd913c32f1b320f63 +config-catalog.zh.md: 381319465dab9dafeecaacf704caba8bab7455f3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6494070f62..0bf883bbfd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -33,6 +33,22 @@ Depends on: `Stream` (`@agentclientprotocol/sdk`) Source: [`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts) + + +## `@deepseek-ai/dsh-activity-local` + +```ts config-catalog +/** Configuration for the process-local observation registry. */ +export interface Config { + /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number +} +``` + +Source: [`packages/activity/activity-local/src/index.ts:31`](../packages/activity/activity-local/src/index.ts) + ## `@deepseek-ai/dsh-agent-default-model` @@ -276,6 +292,24 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md) Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-activity-controller` + +Requires: `typert` + +```ts config-catalog +/** Configuration for the Activity Remote owner. */ +export interface Config { + /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ + flushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + maxFrameBytes?: number +} +``` + +Source: [`packages/api/activity-controller/src/index.ts:26`](../packages/api/activity-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -2559,10 +2593,12 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } ``` -Source: [`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) +Source: [`packages/shell/tool-bash/src/index.ts:36`](../packages/shell/tool-bash/src/index.ts) @@ -2724,10 +2760,12 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } ``` -Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) +Source: [`packages/shell/tool-pwsh/src/index.ts:61`](../packages/shell/tool-pwsh/src/index.ts) @@ -3004,7 +3042,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:33`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts) @@ -3303,6 +3341,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-activity` ([`packages/client/ui-activity/src/index.ts`](../packages/client/ui-activity/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval` ([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) @@ -3376,6 +3415,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). +- `@deepseek-ai/dsh-activity` — abstract `ActivityRegistry` ([`packages/activity/activity/src/index.ts`](../packages/activity/activity/src/index.ts)) - `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index da19d854ca..381319465d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -35,6 +35,22 @@ export interface AcpConfig { 来源:[`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts) + + +## `@deepseek-ai/dsh-activity-local` + +```ts config-catalog +/** Configuration for the process-local observation registry. */ +export interface Config { + /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number +} +``` + +来源:[`packages/activity/activity-local/src/index.ts:31`](../packages/activity/activity-local/src/index.ts) + ## `@deepseek-ai/dsh-agent-default-model` @@ -278,6 +294,24 @@ export interface Config { 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-activity-controller` + +需要:`typert` + +```ts config-catalog +/** Configuration for the Activity Remote owner. */ +export interface Config { + /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ + flushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + maxFrameBytes?: number +} +``` + +来源:[`packages/api/activity-controller/src/index.ts:26`](../packages/api/activity-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -2561,10 +2595,12 @@ export type TokenMeterConfig = Record export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } ``` -来源:[`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) +来源:[`packages/shell/tool-bash/src/index.ts:36`](../packages/shell/tool-bash/src/index.ts) @@ -2726,10 +2762,12 @@ export interface Config { export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } ``` -来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) +来源:[`packages/shell/tool-pwsh/src/index.ts:60`](../packages/shell/tool-pwsh/src/index.ts) @@ -3305,6 +3343,7 @@ export interface Config { - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-activity`([`packages/client/ui-activity/src/index.ts`](../packages/client/ui-activity/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval`([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) @@ -3378,6 +3417,7 @@ export interface Config { 抽象服务类——部署时应改为加载具体的实现包(参见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md))。 +- `@deepseek-ai/dsh-activity` — 抽象 `ActivityRegistry`([`packages/activity/activity/src/index.ts`](../packages/activity/activity/src/index.ts)) - `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — 抽象 `CodeRuntime`([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 33c536f50a..b48dba34e8 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 4ab1275e9309e150504d6a3bdd80792bb1a49ea1 -event-producer-consumer.zh.md: 8d8a401bfdccc74d5774ce5ce7ceac2c548f6c72 +event-producer-consumer.md: ad9a32ed1fd479d1cd14422333f0cd5b6845e55f +event-producer-consumer.zh.md: 783f524b8b31bfcd80237b10f078ed6a27ce13f2 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4ab1275e93..ad9a32ed1f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -69,8 +69,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## Non-harness or undeclared event strings seen in package source diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 8d8a401bfd..783f524b8b 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -71,8 +71,8 @@ | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## 包源码中出现的非 harness 或未声明事件字符串 diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index e43820301d..c7bb24dd77 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: 2b277650c4b9e320183f75845d68f922b31a522e -README.zh.md: 2279857e0d58cb36e8027c196075622344d705ae +README.md: b41c74e46e43ca2fea378ac7e9988136a6ee61ee +README.zh.md: fa4fb55034450bfc5f537ea1a2c0aaccfba469de diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 2b277650c4..b41c74e46e 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -44,6 +44,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | | [jobs.md](jobs.md) | the background-job runtime: branded `JobId`s, the producer contract, consumer views, and `ctx.jobs` service behavior | +| [activity.md](activity.md) | live activity observation: branded `ActivityId`s, the producer handle, non-consuming offset reads, and the wire streams | | [permission-presets.md](permission-presets.md) | the permission-preset layer: `PresetSpec`/`PresetOption`, the derived `custom` state, the log-only `permission/preset` event | | [plan.md](plan.md) | plan mode: the log-only `plan/mode` state, pending-selection flush, `PlanModeConfig`, the `exit_plan_mode` review arc | | [invariants.md](invariants.md) | the runtime-invariant registry: selection `Config`, `InvariantInstaller`/`InvariantFailure`, the empty-companion contract | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 2279857e0d..fa4fb55034 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -44,6 +44,7 @@ | [spill.md](spill.zh.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | | [workflow.md](workflow.zh.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | | [jobs.md](jobs.zh.md) | 后台任务运行时:品牌化 `JobId`、producer 约定、消费方视图和 `ctx.jobs` 服务行为 | +| [activity.md](activity.zh.md) | 实时活动观察:品牌化 `ActivityId`、生产者句柄、非消费 offset 读取与 wire 流 | | [permission-presets.md](permission-presets.zh.md) | 权限预设层:`PresetSpec`/`PresetOption`、派生的 `custom` 状态、仅记日志的 `permission/preset` 事件 | | [plan.md](plan.zh.md) | 计划模式:仅记日志的 `plan/mode` 状态、待定选择的冲刷、`PlanModeConfig`、`exit_plan_mode` 审阅流程 | | [invariants.md](invariants.zh.md) | 运行时不变式注册表:选择配置 `Config`、`InvariantInstaller`/`InvariantFailure`、空配套插件约定 | diff --git a/docs/subsystems/activity.i18n.yaml b/docs/subsystems/activity.i18n.yaml new file mode 100644 index 0000000000..cf285e6ba7 --- /dev/null +++ b/docs/subsystems/activity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/subsystems/activity.md +activity.md: 000bdc30be05eb17d670367df3fd214e7d0af7f3 +activity.zh.md: 1fc79e9d596351a1f818ea83c15d1b5082689b79 diff --git a/docs/subsystems/activity.md b/docs/subsystems/activity.md new file mode 100644 index 0000000000..000bdc30be --- /dev/null +++ b/docs/subsystems/activity.md @@ -0,0 +1,141 @@ +# Live Activity Observation + +English | [中文](activity.zh.md) + +Types shared by streaming-output producers, `ctx.activities`, and the observation consumers. The [observation-seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) owns the design; this page records the cross-package vocabulary from [`packages/activity/activity/src/types.ts`](../../packages/activity/activity/src/types.ts) and the wire frames from [`packages/api/activity-controller/src/types.ts`](../../packages/api/activity-controller/src/types.ts). + +An **activity** is one observable unit of long-running work: an append-only bounded output stream plus live status. The registry is a pure observation plane — it starts nothing, cancels nothing, and is invisible to the model. Producers keep their execution resources and their model-facing surfaces (`ctx.jobs` consuming cursors, tool results); observers read non-consumingly at absolute byte offsets, so any number of them coexist with the model's single consuming cursor. + +## Ids, kinds, and correlation + +`ActivityId` is a [branded id](core.md#branded-ids) generated as `-N`; visibility relies on owner authorization, not id secrecy. `ActivityKindMap` is merge-extensible (`bash` seeded; `pwsh` and `workflow` merged by their producers). `ActivityCorrelation` links one activity to the `CallId` that started the work and the `JobId` carrying its model-facing control surface, so a consumer attaches the stream to an existing card or row without guessing from labels. + +## Producer face + +`open(spec)` returns an `ActivityHandle` with synchronous `append(text, { channel?, gapBefore? })`, `updateDetail(detail)`, and first-wins `end(outcome)`. Offsets advance by each chunk's UTF-8 byte length; `gapBefore` marks producer-side loss so a discontinuity stays visible. After `end` — including a teardown force-end — `append` and `updateDetail` log and drop rather than throw, so a producer's trailing flush cannot break its own teardown. + +`pumpActivityOutput(handle, sources, { pollMs, done })` adapts pull substrates: it copies each non-consuming offset reader (the subprocess `readFrom(fromByte)` family, re-exposed on `ShellProcess.observed`) into the handle at a bounded cadence and drains once more after `done` settles. Push-natural producers (the workflow tool's run mirror) append directly. + +## Observation + +`read(id, from, caller?)` returns the retained chunks overlapping `[from, total)` and the resume offset `next`, consuming nothing. Offsets stay absolute across eviction: `outputEarliest` on the snapshot names the oldest retained byte, and a read below it is `lossy: true`, never an error. Retention is provider policy ([`dsh-activity-local`](../../packages/activity/activity-local/README.md): `retainBytes` live, trimmed to `settledRetainBytes` at settlement). + +Two effect-scoped listener families deliver owner-relative on the jobs-registry terms: `onActivitiesChanged` fires per visible-set change (open, detail, settlement, removal) so consumers re-read whole rows, and `onOutput` signals stream advancement (append or settlement) carrying only the id, so each consumer reads from its own cursor. + +## Wire transport + +[`dsh-api-activity-controller`](../../packages/api/activity-controller/README.md) exposes the plane to browsers as two Remote streams: `activity.control` (baseline plus whole-bucket roster replacement frames) and `activity.observe` (one `opened` anchor, coalesced `output` frames, then the terminal `status` on the same stream, after which the generation closes). Output frames are transient — durable history stays with the producers' tool results, exactly as the [web job display decision](../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md) requires. [`dsh-client-ui-activity`](../../packages/client/ui-activity/README.md) renders the roster in the session header and opens observation streams only while a panel is expanded. + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.activities` — `ActivityRegistry` (abstract seam) + +Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Implementations must honor these semantics: + +- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. +- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource. +- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap. +- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition's own carrier — sees every owner, while one registered under an agent composition's scope sees exactly the agents composed under it. Every listener is contained. + +```ts cordis-catalog +/** + * Validate the spec, attach owner cleanup, and atomically register one + * activity. A rejection leaves no id; after return the producer owns the + * handle and the visible set has changed. + * @param spec - activity identity, owner, and correlation. + * @returns the producer face of the registered activity. + */ +abstract open(spec: ActivityOpen): ActivityHandle + +/** + * List caller-owned and unowned activities in registration order without + * exposing another session's labels or output. + * @param caller - reading agent; a non-agent caller sees only unowned activities. + * @returns fresh snapshots. + */ +abstract list(caller?: Agent): ActivitySnapshot[] + +/** + * Return a fresh snapshot. Throws for an unknown or foreign activity. + * @param id - activity to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ +abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot + +/** + * Read retained output from an absolute byte offset without consuming it. + * Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Throws for an unknown or foreign activity or a negative or non-integer + * offset. + * @param id - activity to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead + +/** + * Register an effect-scoped observer of visible-set changes: opening, + * detail updates, settlement, owner-disposal removal, and the emptying that + * service disposal commits — so an observer re-reads rather than + * accumulating deltas. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned activity changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ +abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void + +/** + * Register an effect-scoped observer of stream advancement — one signal per + * committed append and one at settlement, carrying only the activity id. + * A consumer schedules a {@link read} from its own cursor; the registry + * never pushes payloads. Listeners are contained and never awaited. + * @param listener - receives the id whose stream advanced. + * @returns disposer that unregisters the listener. + */ +abstract onOutput(listener: ActivityOutputListener): () => void +``` + +Types: [Agent](core.md) + +Source: [`packages/activity/activity/src/index.ts`](../../packages/activity/activity/src/index.ts) + + + +### `ctx.activityController` — `ActivityController` + +Host service backing the generated `ctx.remote.activity` namespace. + +```ts cordis-catalog +/** + * Stream a complete roster baseline followed by whole-bucket replacements. + * @param signal - generation cancellation. + * @returns baseline followed by per-owner roster replacement frames. + */ +@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Stream one activity's retained output from an absolute byte offset, then + * its terminal status once settled and drained. Non-consuming: the + * model-facing cursors never observe these reads. + * @param request - target activity and optional resume offset. + * @param signal - generation cancellation. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/activity-controller/src/index.ts`](../../packages/api/activity-controller/src/index.ts) + diff --git a/docs/subsystems/activity.zh.md b/docs/subsystems/activity.zh.md new file mode 100644 index 0000000000..1fc79e9d59 --- /dev/null +++ b/docs/subsystems/activity.zh.md @@ -0,0 +1,141 @@ +# 实时活动观察 + +[English](activity.md) | 中文 + +流式输出生产者、`ctx.activities` 与观察消费者共享的类型。设计归[观察 seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)所有;本页记录 [`packages/activity/activity/src/types.ts`](../../packages/activity/activity/src/types.ts) 的跨包词汇与 [`packages/api/activity-controller/src/types.ts`](../../packages/api/activity-controller/src/types.ts) 的 wire 帧。 + +**activity** 是一个可观察的长时工作单元:一条 append-only 的有界输出流加实时状态。注册表是纯观察面——不启动、不取消任何工作,对模型不可见。生产者保留自己的执行资源与模型面(`ctx.jobs` 消费游标、工具结果);观察者按绝对字节 offset 非消费读取,任意数量的观察者可与模型的单一消费游标共存。 + +## Id、kind 与关联 + +`ActivityId` 是[品牌化 id](core.zh.md#branded-ids),生成为 `-N`;可见性靠 owner 授权而非 id 保密。`ActivityKindMap` 可合并扩展(内置 `bash`;`pwsh` 与 `workflow` 由各自生产者合并)。`ActivityCorrelation` 把一个 activity 关联到发起工作的 `CallId` 与承载其模型控制面的 `JobId`,消费者据此把流挂到已有卡片或行上,不必靠 label 猜。 + +## 生产者面 + +`open(spec)` 返回 `ActivityHandle`:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)` 与 first-wins 的 `end(outcome)`。offset 按每个 chunk 的 UTF-8 字节长度推进;`gapBefore` 标记生产者侧丢失,让断点保持可见。`end` 之后——包括 teardown 强制 end——`append` 与 `updateDetail` 记日志后丢弃而不抛错,生产者的收尾 flush 不会砸掉自己的 teardown。 + +`pumpActivityOutput(handle, sources, { pollMs, done })` 适配 pull 型底座:把每个非消费 offset 读取器(subprocess 的 `readFrom(fromByte)` 家族,经 `ShellProcess.observed` 复出)按有界节奏复制进句柄,并在 `done` 结算后再排干一次。push 天然的生产者(workflow 工具的运行镜像)直接 append。 + +## 观察 + +`read(id, from, caller?)` 返回与 `[from, total)` 重叠的保留 chunk 及续读 offset `next`,绝不消费。offset 跨淘汰稳定:快照上的 `outputEarliest` 指明最老保留字节,低于它的读取得到 `lossy: true` 而非错误。保留量是 provider 策略([`dsh-activity-local`](../../packages/activity/activity-local/README.zh.md):存活 `retainBytes`,结算时裁到 `settledRetainBytes`)。 + +两个 effect 范围的监听族按 jobs 注册表条款做 owner 相对投递:`onActivitiesChanged` 在每次可见集变化(开启、detail、结算、移除)时触发,消费者整读行;`onOutput` 发出流推进信号(append 或结算),只带 id,各消费者按自己的游标读取。 + +## Wire 传输 + +[`dsh-api-activity-controller`](../../packages/api/activity-controller/README.zh.md) 以两条 Remote 流把观察面暴露给浏览器:`activity.control`(baseline + 整桶 roster 替换帧)与 `activity.observe`(一帧 `opened` 锚点、合并的 `output` 帧、同流的终态 `status`,之后本代关闭)。输出帧是瞬态的——持久历史仍在生产者的工具结果里,与 [web job display 决策](../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md)的要求一致。[`dsh-client-ui-activity`](../../packages/client/ui-activity/README.zh.md) 在会话头部渲染 roster,只在面板展开时打开观察流。 + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.activities` — `ActivityRegistry` (abstract seam) + +Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Implementations must honor these semantics: + +- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. +- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource. +- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap. +- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition's own carrier — sees every owner, while one registered under an agent composition's scope sees exactly the agents composed under it. Every listener is contained. + +```ts cordis-catalog +/** + * Validate the spec, attach owner cleanup, and atomically register one + * activity. A rejection leaves no id; after return the producer owns the + * handle and the visible set has changed. + * @param spec - activity identity, owner, and correlation. + * @returns the producer face of the registered activity. + */ +abstract open(spec: ActivityOpen): ActivityHandle + +/** + * List caller-owned and unowned activities in registration order without + * exposing another session's labels or output. + * @param caller - reading agent; a non-agent caller sees only unowned activities. + * @returns fresh snapshots. + */ +abstract list(caller?: Agent): ActivitySnapshot[] + +/** + * Return a fresh snapshot. Throws for an unknown or foreign activity. + * @param id - activity to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ +abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot + +/** + * Read retained output from an absolute byte offset without consuming it. + * Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Throws for an unknown or foreign activity or a negative or non-integer + * offset. + * @param id - activity to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead + +/** + * Register an effect-scoped observer of visible-set changes: opening, + * detail updates, settlement, owner-disposal removal, and the emptying that + * service disposal commits — so an observer re-reads rather than + * accumulating deltas. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned activity changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ +abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void + +/** + * Register an effect-scoped observer of stream advancement — one signal per + * committed append and one at settlement, carrying only the activity id. + * A consumer schedules a {@link read} from its own cursor; the registry + * never pushes payloads. Listeners are contained and never awaited. + * @param listener - receives the id whose stream advanced. + * @returns disposer that unregisters the listener. + */ +abstract onOutput(listener: ActivityOutputListener): () => void +``` + +Types: [Agent](core.zh.md) + +Source: [`packages/activity/activity/src/index.ts`](../../packages/activity/activity/src/index.ts) + + + +### `ctx.activityController` — `ActivityController` + +Host service backing the generated `ctx.remote.activity` namespace. + +```ts cordis-catalog +/** + * Stream a complete roster baseline followed by whole-bucket replacements. + * @param signal - generation cancellation. + * @returns baseline followed by per-owner roster replacement frames. + */ +@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Stream one activity's retained output from an absolute byte offset, then + * its terminal status once settled and drained. Non-consuming: the + * model-facing cursors never observe these reads. + * @param request - target activity and optional resume offset. + * @param signal - generation cancellation. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/activity-controller/src/index.ts`](../../packages/api/activity-controller/src/index.ts) + diff --git a/docs/subsystems/shell.i18n.yaml b/docs/subsystems/shell.i18n.yaml index 1515b4ed7b..4a80f2cea2 100644 --- a/docs/subsystems/shell.i18n.yaml +++ b/docs/subsystems/shell.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/shell.md -shell.md: 554adcfb1b37a5e2fe787a78dbb61a1206f80cf9 -shell.zh.md: f15b57cc0050bb8f38d2652ddb9ad0e250568663 +shell.md: 8635290b98a19403cc1839b8262831cb99995fd5 +shell.zh.md: ad8fa3254444e43501bbba09964bcfbd95d56bf9 diff --git a/docs/subsystems/shell.md b/docs/subsystems/shell.md index 554adcfb1b..8635290b98 100644 --- a/docs/subsystems/shell.md +++ b/docs/subsystems/shell.md @@ -192,6 +192,13 @@ interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. Absent when the + * backend cannot expose offset reads; observers then see no stream. + */ + observed?: ShellObservedStreams /** * Kill the process group. Returns false when it had already finished * (no-op); idempotent. diff --git a/docs/subsystems/shell.zh.md b/docs/subsystems/shell.zh.md index f15b57cc00..ad8fa32544 100644 --- a/docs/subsystems/shell.zh.md +++ b/docs/subsystems/shell.zh.md @@ -192,6 +192,13 @@ interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. Absent when the + * backend cannot expose offset reads; observers then see no stream. + */ + observed?: ShellObservedStreams /** * Kill the process group. Returns false when it had already finished * (no-op); idempotent. diff --git a/knip.json b/knip.json index e8dc6139ee..b6ab568c31 100644 --- a/knip.json +++ b/knip.json @@ -117,6 +117,11 @@ "tests/**/*.ts" ] }, + "packages/api/activity-controller": { + "ignoreDependencies": [ + "zod" + ] + }, "packages/api/workspace-controller": { "ignoreDependencies": [ "zod" diff --git a/packages/activity/README.i18n.yaml b/packages/activity/README.i18n.yaml new file mode 100644 index 0000000000..4044dfcfec --- /dev/null +++ b/packages/activity/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/activity/README.md +README.md: 1988ca73b4a71095d42f85086d4958eb91bad928 +README.zh.md: b75c7bfcfda6aa829d59d1d46439144bdfaa80e6 diff --git a/packages/activity/README.md b/packages/activity/README.md new file mode 100644 index 0000000000..1988ca73b4 --- /dev/null +++ b/packages/activity/README.md @@ -0,0 +1,14 @@ +# activity/ — streaming-output observation family + +English | [中文](README.zh.md) + +This family gives every long-running producer one non-consuming observation plane: an activity is an append-only bounded output stream plus live status, readable by any number of independent observers at absolute byte offsets while the model-facing paths (`ctx.jobs` cursors, tool results) stay untouched. + +| Package | Role | ctx key | +|---|---|---| +| [`activity/`](activity/README.md) | Defines the observation registry and vocabulary | `ctx.activities` | +| [`activity-local/`](activity-local/README.md) | Implements the in-memory ring-buffer registry | registers on `ctx.activities` | + +The Web transport and client consumers live in [`dsh-api-activity-controller`](../api/activity-controller/README.md) and [`dsh-client-ui-activity`](../client/ui-activity/README.md); background bash/pwsh and the workflow tool are the shipped producers. + +The subsystem reference — offsets, retention, listener delivery — is [docs/subsystems/activity.md](../../docs/subsystems/activity.md); design in the [activity observation seam](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) Agent Note. diff --git a/packages/activity/README.zh.md b/packages/activity/README.zh.md new file mode 100644 index 0000000000..b75c7bfcfd --- /dev/null +++ b/packages/activity/README.zh.md @@ -0,0 +1,14 @@ +# activity/ — 流式输出观察能力族 + +[English](README.md) | 中文 + +本能力族为所有长时生产者提供统一的非消费观察面:一个 activity 是一条 append-only 的有界输出流加实时状态,任意数量的独立观察者按绝对字节 offset 读取,而模型侧路径(`ctx.jobs` 游标、工具结果)完全不受影响。 + +| 包 | 角色 | ctx key | +|---|---|---| +| [`activity/`](activity/README.zh.md) | 定义观察注册表与词汇 | `ctx.activities` | +| [`activity-local/`](activity-local/README.zh.md) | 实现内存环形缓冲注册表 | 注册到 `ctx.activities` | + +Web 传输与客户端消费者在 [`dsh-api-activity-controller`](../api/activity-controller/README.zh.md) 与 [`dsh-client-ui-activity`](../client/ui-activity/README.zh.md);后台 bash/pwsh 与 workflow 工具是已接入的生产者。 + +子系统参考——offset、保留策略、监听投递——见 [docs/subsystems/activity.zh.md](../../docs/subsystems/activity.zh.md);设计见 [activity 观察 seam](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md) Agent Note。 diff --git a/packages/activity/activity-local/README.i18n.yaml b/packages/activity/activity-local/README.i18n.yaml new file mode 100644 index 0000000000..adbd77bb80 --- /dev/null +++ b/packages/activity/activity-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/activity/activity-local/README.md +README.md: 511511a6a5164e0ae4be65230e83a2c1ef37797a +README.zh.md: e4bee07b74abf6c75b68245147f3f5b6fd9f3f6c diff --git a/packages/activity/activity-local/README.md b/packages/activity/activity-local/README.md new file mode 100644 index 0000000000..511511a6a5 --- /dev/null +++ b/packages/activity/activity-local/README.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-activity-local + +English | [中文](README.zh.md) + +The in-memory Service Provider for the [`dsh-activity`](../activity/README.md) observation seam: `LocalActivityRegistry` keeps every record and its bounded output ring in process memory and hands out fresh snapshots and chunk copies, never live state. + +## Retention + +Each activity retains a chunk ring whose offsets stay absolute across eviction: dropping the head advances `outputEarliest`, and a single chunk larger than the live cap keeps only its UTF-8-safe tail with a `gapBefore` marker. Settlement trims the ring to the settled cap so a finished activity still shows its tail without holding the live budget. + +| Config | Default | Meaning | +|---|---|---| +| `retainBytes` | `262144` | Live output retention per activity, in UTF-8 bytes. | +| `settledRetainBytes` | `16384` | Retention kept after an activity settles. | + +## Lifecycle + +Records outlive producer fibers. The first activity for an owner attaches one cleanup to the exact `Agent` scope; owner disposal ends still-open records (`killed`, `owner disposed`) and removes them, announcing the removal. Service disposal ends everything, clears the store, and announces each emptied owner. Neither awaits a producer — the registry owns no execution resource. Listeners are layered by registration scope exactly like the jobs registry, so delivery stays owner-relative in one process-wide instance. + +## Model Experience + +None, as the provider stores and serves observation state only; the producers own every model-visible surface. + +#### KV Cache effect + +None; the package never assembles or alters provider requests. + +## Known Limitations and Deferred Work + +- **The unowned bucket has no admission bound** — owned producers are already bounded by the jobs admission policy, but a plugin opening many unowned activities grows the store until service disposal. diff --git a/packages/activity/activity-local/README.zh.md b/packages/activity/activity-local/README.zh.md new file mode 100644 index 0000000000..e4bee07b74 --- /dev/null +++ b/packages/activity/activity-local/README.zh.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-activity-local + +[English](README.md) | 中文 + +[`dsh-activity`](../activity/README.zh.md) 观察 seam 的内存 Service Provider:`LocalActivityRegistry` 把每条记录及其有界输出环保存在进程内存中,对外只给全新快照与 chunk 副本,绝不暴露活状态。 + +## 保留策略 + +每个 activity 保留一个 offset 跨淘汰稳定的 chunk 环:丢弃头部使 `outputEarliest` 前移;单个超过存活上限的 chunk 只保留其 UTF-8 安全的尾部并打上 `gapBefore` 标记。结算时把环裁到 settled 上限,已完成的 activity 仍能展示尾巴而不占存活预算。 + +| 配置 | 默认值 | 含义 | +|---|---|---| +| `retainBytes` | `262144` | 每个 activity 的存活输出保留量(UTF-8 字节)。 | +| `settledRetainBytes` | `16384` | activity 结算后的保留量。 | + +## 生命周期 + +记录的存续期长于生产者 fiber。owner 的第一个 activity 会在 exact `Agent` scope 上挂一个清理;owner 销毁时把未结束的记录终结(`killed`、`owner disposed`)并移除,同时公告移除。服务销毁终结一切、清空存储并逐个公告被清空的 owner。两者都不等待生产者——注册表不拥有任何执行资源。监听按注册 scope 分层,与 jobs 注册表完全一致,一个进程级实例内投递保持 owner 相对。 + +## 模型体验 + +无:provider 只存储与提供观察状态;所有模型可见面都归生产者。 + +#### KV Cache 影响 + +无;本包从不组装或改动 provider 请求。 + +## 已知限制与暂缓事项 + +- **无主桶没有准入上限**——有主生产者已被 jobs 准入策略约束,但插件开出的大量无主 activity 会让存储一直增长到服务销毁。 diff --git a/packages/activity/activity-local/package.json b/packages/activity/activity-local/package.json new file mode 100644 index 0000000000..b06864d251 --- /dev/null +++ b/packages/activity/activity-local/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-activity-local", + "description": "Process-local implementation of the DeepSeek Harness streaming-output observation seam", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/activity/activity-local" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/activity/activity-local/src/index.ts b/packages/activity/activity-local/src/index.ts new file mode 100644 index 0000000000..caabb5499e --- /dev/null +++ b/packages/activity/activity-local/src/index.ts @@ -0,0 +1,438 @@ +/** + * Process-local provider for the streaming-output observation seam + * (`ctx.activities`). It keeps every record and its bounded output ring in + * memory and hands out fresh snapshots and chunk copies, never live state. + * + * Records outlive producer fibers. Owner disposal ends still-open records and + * removes them; service disposal ends and clears everything. Neither awaits a + * producer — the registry owns no execution resource. + * @module @deepseek-ai/dsh-activity-local + */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeLayer } from '@deepseek-ai/dsh-scope' +import { ActivityRegistry, ActivityId } from '@deepseek-ai/dsh-activity' +import type { + ActivityAppendOptions, ActivityChannel, ActivityCorrelation, ActivitiesChangedListener, + ActivityHandle, ActivityKind, ActivityOpen, ActivityOutcome, ActivityOutputChunk, + ActivityOutputListener, ActivityRead, ActivitySnapshot, ActivityStatus, +} from '@deepseek-ai/dsh-activity' + +/** Default live retention per activity, in UTF-8 bytes. */ +const DEFAULT_RETAIN_BYTES = 256 * 1024 + +/** Default retention kept after settlement, in UTF-8 bytes. */ +const DEFAULT_SETTLED_RETAIN_BYTES = 16 * 1024 + +/** Configuration for the process-local observation registry. */ +export interface Config { + /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number +} + +/** The registry's mutable per-activity record (never handed out — see {@link LocalActivityRegistry.snapshot}). */ +interface TrackedActivity { + id: ActivityId + kind: ActivityKind + label: string + /** Exact lifecycle owner; session-id visibility is derived from it. */ + owner: Agent | undefined + correlation: ActivityCorrelation | undefined + status: ActivityStatus + detail: string | undefined + startedAt: number + finishedAt: number | undefined + /** Retained chunks in offset order; offsets stay absolute across eviction. */ + chunks: RingChunk[] + /** Sum of the retained chunks' byte lengths. */ + retainedBytes: number + /** Total UTF-8 bytes ever appended. */ + total: number + /** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */ + earliest: number +} + +/** One retained ring entry; `bytes` caches the chunk's UTF-8 length. */ +interface RingChunk { + at: number + text: string + bytes: number + channel?: ActivityChannel + gapBefore?: true +} + +/** True for the three terminal {@link ActivityStatus} values. */ +function isTerminal(status: ActivityStatus): boolean { + return status === 'completed' || status === 'failed' || status === 'killed' +} + +/** + * The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut + * advances past continuation bytes so the surviving text never starts inside + * a code point. + * @param text - the oversized chunk text. + * @param maxBytes - positive byte budget for the surviving tail. + * @returns the surviving tail and its exact byte length. + */ +function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } { + const raw = Buffer.from(text, 'utf8') + let start = raw.length - maxBytes + // The loop bound proves the index is in range; the assertion only + // discharges noUncheckedIndexedAccess. + while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1 + const tail = raw.subarray(start) + return { text: tail.toString('utf8'), bytes: tail.length } +} + +/** + * One scope's observer contributions. Both tables are anonymous because a + * contribution is identified by its own disposer, never by a name a second + * registrant could shadow. + */ +class ActivityLayer implements ScopeLayer { + readonly changed = new AnonymousEntries() + readonly output = new AnonymousEntries() + + isEmpty(): boolean { + return this.changed.isEmpty() && this.output.isEmpty() + } +} + +/** + * The in-memory `activities` registry. See the Service Definition contract in + * `@deepseek-ai/dsh-activity` for the observation, retention, and lifecycle + * semantics this implementation honors. + */ +export class LocalActivityRegistry extends ActivityRegistry { + static Config: z = z.object({ + retainBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_RETAIN_BYTES), + settledRetainBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_SETTLED_RETAIN_BYTES), + }) + + /** Schemastery-defaulted live retention cap. */ + private readonly retainBytes: number + /** Schemastery-defaulted settled retention cap. */ + private readonly settledRetainBytes: number + private store = new Map() + private counters = new Map() + /** + * Observers layered by the scope that registered them, in the jobs-registry + * shape: a contribution files into its registering context's scope, and a + * dispatch unions the global layer with the subject owner's scope chain, so + * delivery stays owner-relative in one process-wide registry. Nothing + * derives a cache from a layer, so change notification is a no-op. + */ + private readonly layers = new ScopedLayers(() => new ActivityLayer(), () => {}) + /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ + private ownerCleanups = new Map Promise | void>() + /** Service context used by contained listener reporting and teardown. */ + private readonly selfCtx: Context + + constructor(ctx: Context, config: Config) { + super(ctx) + // Schemastery validates and fills the defaults before constructing the service. + this.retainBytes = (config as Required).retainBytes + this.settledRetainBytes = (config as Required).settledRetainBytes + this.selfCtx = ctx + ctx.effect(() => () => { this.disposeAll() }, 'activities teardown') + } + + open(spec: ActivityOpen): ActivityHandle { + if (spec.kind.length === 0) throw new Error('invalid activity kind: expected a non-empty string') + if (spec.label.length === 0) throw new Error('invalid activity label: expected a non-empty string') + if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) + + const count = (this.counters.get(spec.kind) ?? 0) + 1 + this.counters.set(spec.kind, count) + const id = ActivityId(`${spec.kind}-${count}`) + const record: TrackedActivity = { + id, + kind: spec.kind, + label: spec.label, + owner: spec.owner, + correlation: spec.correlation === undefined ? undefined : { ...spec.correlation }, + status: 'running', + detail: undefined, + startedAt: Date.now(), + finishedAt: undefined, + chunks: [], + retainedBytes: 0, + total: 0, + earliest: 0, + } + this.store.set(id, record) + // Registration is complete and cannot fail from here, so the visible set + // has genuinely changed. + this.notifyChanged(record.owner) + return { + id, + append: (text, options) => { this.append(record, text, options) }, + updateDetail: (detail) => { this.updateDetail(record, detail) }, + end: (outcome) => { this.settle(record, outcome) }, + } + } + + list(caller?: Agent): ActivitySnapshot[] { + const session = caller?.id + return [...this.store.values()] + .filter(record => record.owner === undefined || record.owner.id === session) + .map(record => this.snapshot(record)) + } + + get(id: ActivityId, caller?: Agent): ActivitySnapshot { + const record = this.expect(id) + this.assertAccess(record, caller) + return this.snapshot(record) + } + + read(id: ActivityId, from: number, caller?: Agent): ActivityRead { + const record = this.expect(id) + this.assertAccess(record, caller) + if (!Number.isSafeInteger(from) || from < 0) { + throw new Error(`invalid read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`) + } + const chunks: ActivityOutputChunk[] = [] + for (const chunk of record.chunks) { + if (chunk.at + chunk.bytes <= from) continue + chunks.push({ + at: chunk.at, + text: chunk.text, + ...chunk.channel !== undefined ? { channel: chunk.channel } : {}, + ...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {}, + }) + } + return { chunks, next: record.total, lossy: from < record.earliest } + } + + onActivitiesChanged(listener: ActivitiesChangedListener): () => void { + return this.layers.effect( + this.ctx, + layer => layer.changed.append(listener), + { label: 'activities.onActivitiesChanged()' }, + ) + } + + onOutput(listener: ActivityOutputListener): () => void { + return this.layers.effect( + this.ctx, + layer => layer.output.append(listener), + { label: 'activities.onOutput()' }, + ) + } + + /** Append one chunk to a live record; log and drop on an ended one. */ + private append(record: TrackedActivity, text: string, options?: ActivityAppendOptions): void { + if (isTerminal(record.status)) { + this.selfCtx.logger.warn(`activities: append to ended activity ${record.id} dropped`) + return + } + if (text.length === 0) return + const bytes = Buffer.byteLength(text, 'utf8') + record.chunks.push({ + at: record.total, + text, + bytes, + ...options?.channel !== undefined ? { channel: options.channel } : {}, + ...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {}, + }) + record.total += bytes + record.retainedBytes += bytes + this.trim(record, this.retainBytes) + this.notifyOutput(record) + } + + /** Replace a live record's detail line; log and drop on an ended one. */ + private updateDetail(record: TrackedActivity, detail: string): void { + if (isTerminal(record.status)) { + this.selfCtx.logger.warn(`activities: detail update on ended activity ${record.id} dropped`) + return + } + record.detail = detail + this.notifyChanged(record.owner) + } + + /** + * Record the first terminal outcome, trim retention to the settled cap, and + * announce the change and the stream end. First-wins: a later call — the + * producer's own report after a teardown force-end, or a duplicate — is + * dropped silently. + */ + private settle(record: TrackedActivity, outcome: ActivityOutcome): void { + if (isTerminal(record.status)) return + record.status = outcome.status + if (outcome.detail !== undefined) record.detail = outcome.detail + record.finishedAt = Date.now() + this.trim(record, this.settledRetainBytes) + this.notifyChanged(record.owner) + this.notifyOutput(record) + } + + /** + * Drop retained head chunks until the ring fits `cap`; a single oversized + * chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. Offsets + * stay absolute: `earliest` advances over everything dropped. + */ + private trim(record: TrackedActivity, cap: number): void { + while (record.retainedBytes > cap && record.chunks.length > 1) { + const dropped = record.chunks.shift() + /* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */ + if (dropped === undefined) break + record.retainedBytes -= dropped.bytes + } + const single = record.chunks.length === 1 ? record.chunks[0] : undefined + if (single !== undefined && single.bytes > cap) { + const tail = utf8Tail(single.text, cap) + single.at += single.bytes - tail.bytes + single.text = tail.text + single.bytes = tail.bytes + single.gapBefore = true + record.retainedBytes = tail.bytes + } + record.earliest = record.chunks[0]?.at ?? record.total + } + + /** Look up an activity or fail loud. */ + private expect(id: ActivityId): TrackedActivity { + const record = this.store.get(id) + if (record === undefined) throw new Error(`unknown activity ${id}`) + return record + } + + /** + * The visibility fence: an activity with an owner is reachable only by + * callers whose session id matches (`!== undefined` semantics — an unowned + * activity is open, and a no-agent caller can never match an owned one). + */ + private assertAccess(record: TrackedActivity, caller?: Agent): void { + if (record.owner !== undefined && record.owner.id !== caller?.id) { + throw new Error(`activity ${record.id} belongs to another session`) + } + } + + /** Project a fresh read-only snapshot from the mutable record. */ + private snapshot(record: TrackedActivity): ActivitySnapshot { + const ownerSession = record.owner?.id + return { + id: record.id, + kind: record.kind, + label: record.label, + ...ownerSession !== undefined ? { ownerSession } : {}, + ...record.correlation !== undefined ? { correlation: { ...record.correlation } } : {}, + status: record.status, + ...record.detail !== undefined ? { detail: record.detail } : {}, + startedAt: record.startedAt, + ...record.finishedAt !== undefined ? { finishedAt: record.finishedAt } : {}, + outputTotal: record.total, + outputEarliest: record.earliest, + } + } + + /** + * The change observers that own `owner`'s updates: the global layer — a + * host composition's own carrier, which serves every owner — then each + * scoped layer along the owner's chain. + */ + private *changedFor(owner?: Agent): IterableIterator { + yield* this.layers.global.changed.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.changed.values() + } + + /** The output observers that own `owner`'s stream signals, resolved like {@link changedFor}. */ + private *outputFor(owner?: Agent): IterableIterator { + yield* this.layers.global.output.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.output.values() + } + + /** + * Announce that one owner's visible set changed. Each listener is contained + * so an observer cannot break a lifecycle commit that already happened. + */ + private notifyChanged(owner: Agent | undefined): void { + for (const listener of this.changedFor(owner)) { + try { + listener(owner) + } catch (error: unknown) { + this.selfCtx.logger.warn(`activities: onActivitiesChanged listener threw: ${String(error)}`) + } + } + } + + /** Announce that one activity's stream advanced (append or settlement), with per-listener containment. */ + private notifyOutput(record: TrackedActivity): void { + for (const listener of this.outputFor(record.owner)) { + try { + listener(record.id) + } catch (error: unknown) { + this.selfCtx.logger.warn(`activities: onOutput listener threw: ${String(error)}`) + } + } + } + + /** + * Attach one cleanup through the exact owner's scope. This survives + * producer reloads; the retained disposer lets service teardown detach the + * cross-fiber effect. Fails when the agent registry is absent or the owner + * is not its currently registered instance. + */ + private ensureOwnerCleanup(owner: Agent): void { + const ownerId = owner.id + const agents = this.selfCtx.get('agents') + if (agents === undefined) { + throw new Error('activity ownership requires the agent registry (load @deepseek-ai/dsh-agent)') + } + if (agents.get(ownerId) !== owner) { + throw new Error(`agent "${ownerId}" is not the registered agent instance (activity owner must be live)`) + } + if (this.ownerCleanups.has(owner)) return + // Record only after attach succeeds; a disposing scope rejects new effects. + const detach = owner.ctx.effect(() => () => { + this.ownerCleanups.delete(owner) + this.disposeOwned(owner) + }, 'activities.ownerCleanup()') + this.ownerCleanups.set(owner, detach) + } + + /** End still-open records and drop every activity owned by one exact agent lifecycle. */ + private disposeOwned(owner: Agent): void { + const owned = [...this.store.values()].filter(record => record.owner === owner) + for (const record of owned) { + if (!isTerminal(record.status)) this.settle(record, { status: 'killed', detail: 'owner disposed' }) + this.store.delete(record.id) + } + // Removal is the one visible-set change no per-activity record carries, so + // it must be announced here or an observer keeps the dropped rows forever. + if (owned.length > 0) this.notifyChanged(owner) + } + + /** End every open record, clear the store, and announce each emptied owner. */ + private disposeAll(): void { + const all = [...this.store.values()] + for (const record of all) { + if (!isTerminal(record.status)) this.settle(record, { status: 'killed', detail: 'activity registry disposed' }) + } + const emptied = new Set(all.map(record => record.owner)) + this.store.clear() + for (const owner of emptied) this.notifyChanged(owner) + // Detach cross-fiber owner effects after the shared store is quiescent. + const ownerCleanups = [...this.ownerCleanups.values()] + this.ownerCleanups.clear() + for (const cleanup of ownerCleanups) void cleanup() + } +} + +export default LocalActivityRegistry diff --git a/packages/activity/activity-local/src/invariant.ts b/packages/activity/activity-local/src/invariant.ts new file mode 100644 index 0000000000..7e15b7db09 --- /dev/null +++ b/packages/activity/activity-local/src/invariant.ts @@ -0,0 +1,33 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-activity-local`. + * @module @deepseek-ai/dsh-activity-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-activity-local' + +/** Cordis companion plugin name. */ +export const name = 'activity-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: `@deepseek-ai/dsh-activity/invariant` owns per-snapshot identity, status, + * timestamp, and offset checks on every visible-set change. This provider's remaining behavior — + * ring retention against private byte caps and pull-only chunk reads — has no authoritative event + * stream to observe: a read happens on a caller's schedule, and repeating the retention arithmetic + * against private configuration would duplicate the implementation rather than check a relation. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/activity/activity-local/tests/activity.spec.ts b/packages/activity/activity-local/tests/activity.spec.ts new file mode 100644 index 0000000000..557046d617 --- /dev/null +++ b/packages/activity/activity-local/tests/activity.spec.ts @@ -0,0 +1,413 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createScope } from '@deepseek-ai/dsh-scope' +import { ActivityId } from '@deepseek-ai/dsh-activity' +import type { ActivityHandle, ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import LocalActivityRegistry, { type Config as ProcessConfig } from '@deepseek-ai/dsh-activity-local' + +declare module '@deepseek-ai/dsh-activity' { + interface ActivityKindMap { + workflow: 'workflow' + } +} + +const agentScopeDisposers = new WeakMap Promise>() + +/** + * A minimal live agent whose ctx carries its own scope on a disposable plugin + * fiber, mirroring the harness convention that a live agent keys its scope. + */ +function stubAgent(ctx: Context, rawId: string): Agent { + const id = SessionId(rawId) + const scopeFiber = ctx.plugin(() => {}) + const agentCtx = createScope(scopeFiber.ctx, {}).ctx + const session = Session.create(id) + const agent = { + id, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle' as const, + ctx: agentCtx, + send: () => {}, + followup: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), + inject: () => {}, + cancel() {}, + runMaintenance: (job: (signal: AbortSignal) => Promise) => job(new AbortController().signal), + whenIdle() { return Promise.resolve() }, + } + agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() }) + return agent +} + +async function disposeAgentScope(agent: Agent): Promise { + const dispose = agentScopeDisposers.get(agent) + if (dispose === undefined) throw new Error(`missing test scope for agent "${agent.id}"`) + await dispose() +} + +async function harness(config: ProcessConfig = {}) { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry, config) + return ctx +} + +/** Concatenated retained text of one process, as an observer from `from` sees it. */ +function textFrom(ctx: Context, id: ActivityId, from = 0): string { + return ctx.activities.read(id, from).chunks.map(chunk => chunk.text).join('') +} + +describe('LocalActivityRegistry.open', () => { + it('mints per-kind ordinal ids and announces the new visible row', async () => { + const ctx = await harness() + const changes: (Agent | undefined)[] = [] + ctx.activities.onActivitiesChanged((owner) => { changes.push(owner) }) + const first = ctx.activities.open({ kind: 'bash', label: 'sleep 60' }) + const second = ctx.activities.open({ kind: 'workflow', label: 'run docs' }) + const third = ctx.activities.open({ kind: 'bash', label: 'build' }) + expect([first.id, second.id, third.id]).toEqual(['bash-1', 'workflow-1', 'bash-2']) + expect(changes).toEqual([undefined, undefined, undefined]) + expect(ctx.activities.list().map(snapshot => snapshot.id)).toEqual(['bash-1', 'workflow-1', 'bash-2']) + }) + + it('rejects an empty kind or label without minting an id', async () => { + const ctx = await harness() + expect(() => ctx.activities.open({ kind: '' as never, label: 'x' })).toThrow(/invalid activity kind/) + expect(() => ctx.activities.open({ kind: 'bash', label: '' })).toThrow(/invalid activity label/) + expect(ctx.activities.list()).toEqual([]) + }) + + it('rejects an owned open when no agent registry is loaded', async () => { + const bare = new Context() + await bare.plugin(LocalActivityRegistry) + const ghost = { id: SessionId('ghost'), ctx: bare } as unknown as Agent + expect(() => bare.activities.open({ kind: 'bash', label: 'x', owner: ghost })) + .toThrow(/activity ownership requires the agent registry/) + }) + + it('rejects an owner that is not the registered agent instance', async () => { + const ctx = await harness() + const ghost = stubAgent(ctx, 'ghost') + expect(() => ctx.activities.open({ kind: 'bash', label: 'x', owner: ghost })) + .toThrow(/not the registered agent instance/) + }) + + it('carries owner session and correlation into snapshots', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const handle = ctx.activities.open({ + kind: 'bash', + label: 'npm test', + owner, + correlation: { jobId: 'bash-9' as never, callId: 'call-3' as never }, + }) + const snapshot = ctx.activities.get(handle.id, owner) + expect(snapshot.ownerSession).toBe(owner.id) + expect(snapshot.correlation).toEqual({ jobId: 'bash-9', callId: 'call-3' }) + expect(snapshot.status).toBe('running') + }) +}) + +describe('LocalActivityRegistry output', () => { + it('appends advance absolute offsets and reads are non-consuming', async () => { + const ctx = await harness() + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('hello ') + handle.append('world', { channel: 'stderr' }) + const first = ctx.activities.read(handle.id, 0) + expect(first.lossy).toBe(false) + expect(first.next).toBe(11) + expect(first.chunks).toEqual([ + { at: 0, text: 'hello ' }, + { at: 6, text: 'world', channel: 'stderr' }, + ]) + // A second identical read proves nothing was consumed. + expect(ctx.activities.read(handle.id, 0)).toEqual(first) + // Resuming from `next` yields nothing until more output arrives. + expect(ctx.activities.read(handle.id, first.next).chunks).toEqual([]) + handle.append('!') + expect(ctx.activities.read(handle.id, first.next).chunks).toEqual([{ at: 11, text: '!' }]) + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.outputTotal).toBe(12) + expect(snapshot.outputEarliest).toBe(0) + }) + + it('drops an empty append without waking observers', async () => { + const ctx = await harness() + const signals: ActivityId[] = [] + ctx.activities.onOutput((id) => { signals.push(id) }) + const handle = ctx.activities.open({ kind: 'bash', label: 'quiet' }) + handle.append('') + expect(signals).toEqual([]) + expect(ctx.activities.get(handle.id).outputTotal).toBe(0) + }) + + it('rejects a negative or fractional read offset', async () => { + const ctx = await harness() + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + expect(() => ctx.activities.read(handle.id, -1)).toThrow(/invalid read offset/) + expect(() => ctx.activities.read(handle.id, 0.5)).toThrow(/invalid read offset/) + }) + + it('evicts whole head chunks past the live cap and flags stale readers lossy', async () => { + const ctx = await harness({ retainBytes: 8 }) + const handle = ctx.activities.open({ kind: 'bash', label: 'spam' }) + handle.append('aaaa') + handle.append('bbbb') + handle.append('cccc') + const read = ctx.activities.read(handle.id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 4, text: 'bbbb' }, { at: 8, text: 'cccc' }]) + expect(read.next).toBe(12) + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.outputEarliest).toBe(4) + expect(snapshot.outputTotal).toBe(12) + // A reader at the retained boundary is not lossy. + expect(ctx.activities.read(handle.id, 4).lossy).toBe(false) + }) + + it('keeps only the UTF-8-safe tail of a single oversized chunk', async () => { + const ctx = await harness({ retainBytes: 5 }) + const handle = ctx.activities.open({ kind: 'bash', label: 'wide' }) + // '你好' is six UTF-8 bytes; a five-byte budget must not split the second code point. + handle.append('你好') + const read = ctx.activities.read(handle.id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 3, text: '好', gapBefore: true }]) + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.outputEarliest).toBe(3) + expect(snapshot.outputTotal).toBe(6) + }) + + it('returns the whole overlapped chunk for a foreign mid-chunk offset', async () => { + const ctx = await harness() + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('abcdef') + const read = ctx.activities.read(handle.id, 3) + expect(read.chunks).toEqual([{ at: 0, text: 'abcdef' }]) + expect(read.lossy).toBe(false) + }) + + it('preserves a producer-reported gap marker through reads', async () => { + const ctx = await harness() + const handle = ctx.activities.open({ kind: 'bash', label: 'lossy source' }) + handle.append('tail after drop', { gapBefore: true, channel: 'stdout' }) + expect(ctx.activities.read(handle.id, 0).chunks).toEqual([ + { at: 0, text: 'tail after drop', channel: 'stdout', gapBefore: true }, + ]) + }) +}) + +describe('LocalActivityRegistry settlement', () => { + it('records the first terminal outcome and trims retention to the settled cap', async () => { + const ctx = await harness({ retainBytes: 1024, settledRetainBytes: 4 }) + const handle = ctx.activities.open({ kind: 'bash', label: 'build' }) + handle.append('abcdefgh') + handle.end({ status: 'completed', detail: 'exit code: 0' }) + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.status).toBe('completed') + expect(snapshot.detail).toBe('exit code: 0') + expect(snapshot.finishedAt).toBeDefined() + const read = ctx.activities.read(handle.id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 4, text: 'efgh', gapBefore: true }]) + }) + + it('is first-wins: a later end and post-end writes are dropped without throwing', async () => { + const ctx = await harness() + const warn = vi.fn() + ctx.logger.warn = warn as never + const handle = ctx.activities.open({ kind: 'bash', label: 'build' }) + handle.end({ status: 'killed', detail: 'owner disposed' }) + handle.end({ status: 'completed' }) + handle.append('late') + handle.updateDetail('late detail') + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.status).toBe('killed') + expect(snapshot.detail).toBe('owner disposed') + expect(snapshot.outputTotal).toBe(0) + expect(warn.mock.calls.map(call => String(call[0]))).toEqual([ + expect.stringContaining('append to ended activity'), + expect.stringContaining('detail update on ended activity'), + ]) + }) + + it('signals observers for appends and once more at settlement', async () => { + const ctx = await harness() + const signals: ActivityId[] = [] + ctx.activities.onOutput((id) => { signals.push(id) }) + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('a') + handle.append('b') + handle.end({ status: 'completed' }) + expect(signals).toEqual([handle.id, handle.id, handle.id]) + }) + + it('updateDetail changes the visible row without touching output offsets', async () => { + const ctx = await harness() + const changes: (Agent | undefined)[] = [] + ctx.activities.onActivitiesChanged((owner) => { changes.push(owner) }) + const handle = ctx.activities.open({ kind: 'workflow', label: 'docs run' }) + handle.updateDetail('3/10 agents done') + expect(ctx.activities.get(handle.id).detail).toBe('3/10 agents done') + expect(ctx.activities.get(handle.id).outputTotal).toBe(0) + expect(changes).toHaveLength(2) + }) +}) + +describe('LocalActivityRegistry visibility and delivery', () => { + it('fences owned processes by session id and keeps unowned ones open', async () => { + const ctx = await harness() + const alice = stubAgent(ctx, 'alice') + const bob = stubAgent(ctx, 'bob') + ctx.agents.register(alice) + ctx.agents.register(bob) + const owned = ctx.activities.open({ kind: 'bash', label: 'alice job', owner: alice }) + const open = ctx.activities.open({ kind: 'bash', label: 'shared' }) + owned.append('secret') + expect(ctx.activities.list(alice).map(snapshot => snapshot.id)).toEqual([owned.id, open.id]) + expect(ctx.activities.list(bob).map(snapshot => snapshot.id)).toEqual([open.id]) + expect(ctx.activities.list().map(snapshot => snapshot.id)).toEqual([open.id]) + expect(() => ctx.activities.get(owned.id, bob)).toThrow(/belongs to another session/) + expect(() => ctx.activities.read(owned.id, 0)).toThrow(/belongs to another session/) + expect(textFrom(ctx, open.id)).toBe('') + expect(ctx.activities.read(owned.id, 0, alice).chunks[0]?.text).toBe('secret') + }) + + it('delivers scoped listeners only their own agents and global listeners everything', async () => { + const ctx = await harness() + const alice = stubAgent(ctx, 'alice') + const bob = stubAgent(ctx, 'bob') + ctx.agents.register(alice) + ctx.agents.register(bob) + const globalOwners: (string | undefined)[] = [] + ctx.activities.onActivitiesChanged((owner) => { globalOwners.push(owner?.id) }) + const scopedOwners: (string | undefined)[] = [] + await alice.ctx.plugin({ + inject: ['activities'], + apply(scopedCtx: Context) { + scopedCtx.activities.onActivitiesChanged((owner) => { scopedOwners.push(owner?.id) }) + }, + }) + const scopedOutputs: string[] = [] + const scopedFiber = await alice.ctx.plugin({ + inject: ['activities'], + apply(scopedCtx: Context) { + scopedCtx.activities.onOutput((id) => { scopedOutputs.push(String(id)) }) + }, + }) + const aliceHandle = ctx.activities.open({ kind: 'bash', label: 'alice work', owner: alice }) + aliceHandle.append('scoped delivery probe') + expect(scopedOutputs).toEqual([String(aliceHandle.id)]) + ctx.activities.open({ kind: 'bash', label: 'bob work', owner: bob }) + ctx.activities.open({ kind: 'bash', label: 'unowned' }) + expect(globalOwners).toEqual(['alice', 'bob', undefined]) + expect(scopedOwners).toEqual(['alice']) + // Disposing one scoped registrant probes layer reclamation; the sibling + // scoped changed-listener keeps the layer alive and keeps delivering. + await scopedFiber.dispose() + ctx.activities.open({ kind: 'bash', label: 'alice later', owner: alice }) + expect(scopedOwners).toEqual(['alice', 'alice']) + + // A scope whose only contribution goes empties on disposal and is reclaimed. + const bobFiber = await bob.ctx.plugin({ + inject: ['activities'], + apply(scopedCtx: Context) { + scopedCtx.activities.onOutput(() => {}) + }, + }) + await bobFiber.dispose() + }) + + it('contains throwing listeners without breaking the commit', async () => { + const ctx = await harness() + const warn = vi.fn() + ctx.logger.warn = warn as never + ctx.activities.onActivitiesChanged(() => { throw new Error('changed boom') }) + ctx.activities.onOutput(() => { throw new Error('output boom') }) + const seen: (Agent | undefined)[] = [] + ctx.activities.onActivitiesChanged((owner) => { seen.push(owner) }) + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('x') + expect(seen).toHaveLength(1) + expect(ctx.activities.get(handle.id).outputTotal).toBe(1) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('onActivitiesChanged listener threw')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('onOutput listener threw')) + }) + + it('unregisters listeners with their registering fiber', async () => { + const ctx = await harness() + const signals: ActivityId[] = [] + const fiber = ctx.plugin({ + inject: ['activities'], + apply(pluginCtx: Context) { + pluginCtx.activities.onOutput((id) => { signals.push(id) }) + }, + }) + await fiber + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('a') + expect(signals).toHaveLength(1) + await fiber.dispose() + handle.append('b') + expect(signals).toHaveLength(1) + }) +}) + +describe('LocalActivityRegistry lifecycle', () => { + it('owner disposal ends open records, removes rows, and announces the removal', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const handle = ctx.activities.open({ kind: 'bash', label: 'sleep', owner }) + handle.append('partial') + const settledEarly = ctx.activities.open({ kind: 'bash', label: 'done already', owner }) + settledEarly.end({ status: 'completed' }) + const changes: (string | undefined)[] = [] + ctx.activities.onActivitiesChanged((changed) => { changes.push(changed?.id) }) + await disposeAgentScope(owner) + expect(ctx.activities.list(owner)).toEqual([]) + expect(() => ctx.activities.get(handle.id, owner)).toThrow(/unknown activity/) + // The forced end and the removal both announced under the owner. + expect(changes.length).toBeGreaterThanOrEqual(2) + expect(new Set(changes)).toEqual(new Set(['owner'])) + // The producer's own late report is dropped silently, not thrown. + expect(() => { handle.end({ status: 'completed' }) }).not.toThrow() + }) + + it('service disposal ends everything, clears the store, and announces emptied owners', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const registryFiber = ctx.plugin(LocalActivityRegistry, {}) + await registryFiber + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const processes = ctx.activities + const owned = processes.open({ kind: 'bash', label: 'owned', owner }) + processes.open({ kind: 'bash', label: 'unowned' }) + const preSettled = processes.open({ kind: 'bash', label: 'already settled' }) + preSettled.end({ status: 'completed' }) + const changes: (string | undefined)[] = [] + ctx.activities.onActivitiesChanged((changed) => { changes.push(changed?.id) }) + await registryFiber.dispose() + expect(changes).toEqual(expect.arrayContaining(['owner', undefined])) + expect(() => { owned.append('late') }).not.toThrow() + }) +}) + +describe('LocalActivityRegistry snapshots', () => { + it('hands out fresh snapshots, never live registry state', async () => { + const ctx = await harness() + const handle: ActivityHandle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + const before: ActivitySnapshot = ctx.activities.get(handle.id) + handle.append('grow') + expect(before.outputTotal).toBe(0) + expect(ctx.activities.get(handle.id).outputTotal).toBe(4) + }) +}) diff --git a/packages/activity/activity-local/tests/loader-composition.spec.ts b/packages/activity/activity-local/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..4b341f7e16 --- /dev/null +++ b/packages/activity/activity-local/tests/loader-composition.spec.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('process-local through a real Loader composition', () => { + it('applies the provider-owned retention config from a Cordis row', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-activity-local-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-activity-local'", + ' config:', + ' retainBytes: 8', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (specifier === '@deepseek-ai/dsh-activity-local') return LocalActivityRegistry + throw new Error(`unexpected Loader import: ${specifier}`) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + + expect(context.activities).toBeInstanceOf(LocalActivityRegistry) + const handle = context.activities.open({ kind: 'bash', label: 'loader retention probe' }) + handle.append('aaaa') + handle.append('bbbb') + handle.append('cccc') + const read = context.activities.read(handle.id, 0) + // retainBytes: 8 from the Cordis row dropped the oldest chunk. + expect(read.lossy).toBe(true) + expect(read.chunks.map(chunk => chunk.text)).toEqual(['bbbb', 'cccc']) + expect(read.next).toBe(12) + }) +}) diff --git a/packages/activity/activity-local/tsconfig.json b/packages/activity/activity-local/tsconfig.json new file mode 100644 index 0000000000..0bddbff854 --- /dev/null +++ b/packages/activity/activity-local/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/scope" + }, + { + "path": "../activity" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/activity/activity/README.i18n.yaml b/packages/activity/activity/README.i18n.yaml new file mode 100644 index 0000000000..e8cb7b1bad --- /dev/null +++ b/packages/activity/activity/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/activity/activity/README.md +README.md: a6d465d4d962b3bee59b56cfed1a4b0b1955c560 +README.zh.md: afe843f40a57103436e4c63799d80b86a9c4557a diff --git a/packages/activity/activity/README.md b/packages/activity/activity/README.md new file mode 100644 index 0000000000..a6d465d4d9 --- /dev/null +++ b/packages/activity/activity/README.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-activity + +English | [中文](README.zh.md) + +The streaming-output observation contract (`ctx.activities`). The abstract `ActivityRegistry` and its vocabulary types give any long-running producer one place to publish live output and status, and any number of independent consumers non-consuming reads over it; the in-memory registry lives in [`dsh-activity-local`](../activity-local/README.md). Producer plugins extend `ActivityKindMap` with their opaque id namespace and may link an activity to its tool call and background job through `ActivityCorrelation`. + +The registry is a pure observation plane: it starts nothing, cancels nothing, and is invisible to the model. Producers keep their execution resources and their existing model-facing surfaces; a composition without this seam loses only live observation. + +## Service contract + +- `open(spec): ActivityHandle` validates the kind, label, and exact live owner, then atomically registers the record and returns the producer face: synchronous `append(text, { channel?, gapBefore? })`, `updateDetail(detail)`, and first-wins `end(outcome)`. After `end` — including a teardown force-end — `append` and `updateDetail` log and drop instead of throwing, so a producer's trailing flush cannot break its own teardown. +- `get(id, caller?)` and `list(caller?)` return fresh snapshots. Listing includes only caller-owned and unowned activities; owned access compares the activity's `SessionId` with the caller's, and predictable ids make that fence the boundary. +- `read(id, from, caller?)` returns the retained chunks overlapping `[from, total)` plus the resume offset, without consuming anything. Offsets are absolute UTF-8 byte positions that survive eviction; a reader below the oldest retained byte gets `lossy: true`, never an error. +- `onActivitiesChanged(listener)` observes visible-set changes — opening, detail updates, settlement, owner-disposal removal, and the emptying service disposal commits — so consumers re-read rather than accumulate deltas. `onOutput(listener)` signals stream advancement (one signal per committed append and one at settlement) carrying only the id; a consumer reads from its own cursor. Both deliver owner-relative on the jobs-registry terms and contain every listener. + +`pumpActivityOutput(handle, sources, { pollMs, done })` is the shared pump for producers whose substrate is a non-consuming offset reader (the subprocess `readFrom(fromByte)` family): it copies labeled deltas at a bounded cadence, marks a lossy source read `gapBefore`, and drains once more after `done` settles without ever calling `end()` itself. + +See the [activity subsystem page](../../../docs/subsystems/activity.md) and the [observation-seam Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md). + +## Model Experience + +None, as the registry carries live observation state for humans and registers no prompt, tool, or session event; activity output becomes model-visible only through the producers' existing tool results and `ctx.jobs` reads. + +#### KV Cache effect + +None; the package never assembles or alters provider requests. + +## Known Limitations and Deferred Work + +- **Records are process-local and live-only** — a Host restart empties every roster while the transcript keeps the producing tool cards; durable replay is a separate design. +- **The observation plane has no cancellation verb** — stopping work stays with the producers and `ctx.jobs`; a panel-initiated kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). +- **Settled records last until owner disposal** — retention trims their buffers to the settled cap, but the rows themselves are not aged out. diff --git a/packages/activity/activity/README.zh.md b/packages/activity/activity/README.zh.md new file mode 100644 index 0000000000..afe843f40a --- /dev/null +++ b/packages/activity/activity/README.zh.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-activity + +[English](README.md) | 中文 + +流式输出观察契约(`ctx.activities`)。抽象 `ActivityRegistry` 与其词汇类型给所有长时生产者一个发布实时输出与状态的位置,并让任意数量的独立消费者对其做非消费读取;内存实现在 [`dsh-activity-local`](../activity-local/README.zh.md)。生产者插件通过 `ActivityKindMap` 扩展自己的不透明 id 命名空间,并可经 `ActivityCorrelation` 把 activity 关联到它的工具调用与后台 job。 + +注册表是纯观察面:不启动、不取消任何工作,对模型不可见。生产者保留自己的执行资源与既有模型面;不装本 seam 的组合只失去实时观察。 + +## 服务契约 + +- `open(spec): ActivityHandle` 校验 kind、label 与 exact 存活 owner 后原子注册记录并返回生产者句柄:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)` 与 first-wins 的 `end(outcome)`。`end` 之后——包括 teardown 强制 end——`append` 与 `updateDetail` 记日志后丢弃而不抛错,生产者的收尾 flush 不会砸掉自己的 teardown。 +- `get(id, caller?)` 与 `list(caller?)` 返回全新快照。列表只含调用方拥有与无主的 activity;有主访问比较 activity 的 `SessionId` 与调用方的,id 可预测,这道栅栏即边界。 +- `read(id, from, caller?)` 返回与 `[from, total)` 重叠的保留 chunk 及续读 offset,绝不消费。offset 是跨淘汰稳定的绝对 UTF-8 字节位置;落后于最老保留字节的读者得到 `lossy: true`,永不报错。 +- `onActivitiesChanged(listener)` 观察可见集变化——开启、detail 更新、结算、owner 销毁移除、服务销毁清空——消费者整读而非累积增量。`onOutput(listener)` 发出流推进信号(每次提交的 append 一发、结算再一发),只携带 id;消费者按自己的游标去 read。两者按 jobs 注册表的条款做 owner 相对投递,且每个监听都被容错。 + +`pumpActivityOutput(handle, sources, { pollMs, done })` 是给非消费 offset 读取器型底座(subprocess 的 `readFrom(fromByte)` 家族)的共享泵:按有界节奏复制带标签的增量、把 lossy 的源读取标为 `gapBefore`、在 `done` 结算后再排干一次,且自己绝不调用 `end()`。 + +参见 [activity 子系统页](../../../docs/subsystems/activity.zh.md)与[观察 seam Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)。 + +## 模型体验 + +无:注册表只承载面向人的实时观察状态,不注册任何 prompt、工具或会话事件;activity 输出只有经生产者既有的工具结果与 `ctx.jobs` 读取才对模型可见。 + +#### KV Cache 影响 + +无;本包从不组装或改动 provider 请求。 + +## 已知限制与暂缓事项 + +- **记录是进程本地且仅存活期有效**——Host 重启清空全部 roster,而 transcript 侧的生产工具卡片仍在;持久回放是独立设计。 +- **观察面没有取消动词**——停止工作仍属于生产者与 `ctx.jobs`;面板发起的 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。 +- **已结算记录保留到 owner 销毁**——保留策略把缓冲裁到 settled 上限,但行本身不会老化清除。 diff --git a/packages/activity/activity/package.json b/packages/activity/activity/package.json new file mode 100644 index 0000000000..0b381d11fa --- /dev/null +++ b/packages/activity/activity/package.json @@ -0,0 +1,57 @@ +{ + "name": "@deepseek-ai/dsh-activity", + "description": "Streaming-output observation registry (ctx.activities) for the DeepSeek Harness — non-consuming offset reads, bounded retention, and change signals over any long-running producer's live output", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/activity/activity" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/activity/activity/src/brand.ts b/packages/activity/activity/src/brand.ts new file mode 100644 index 0000000000..8df786ffb9 --- /dev/null +++ b/packages/activity/activity/src/brand.ts @@ -0,0 +1,28 @@ +/** + * dsh-activity' owned branded id, carried across the registry, the observation + * wire, and correlation metadata on other domains' rows. + * + * It lives in its own leaf because the package root reaches `dsh-agent` + * through the owner and listener signatures, which a Client program cannot + * resolve even as a type. A browser-safe consumer imports the id here; + * `Branded` itself comes from the zero-dependency `@deepseek-ai/dsh-brand`. + * + * @module @deepseek-ai/dsh-activity/brand + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** + * Identifies one observable activity. The registry generates `-N`; + * predictable ids rely on owner authorization rather than secrecy. + */ +export type ActivityId = Branded<'ActivityId'> + +/** + * Brand a string as a {@link ActivityId}. + * @param id - the raw process-id string (the registry generates `-N`). + * @returns the same string, branded; no validation is performed. + */ +export function ActivityId(id: string): ActivityId { + return id as ActivityId +} diff --git a/packages/activity/activity/src/index.ts b/packages/activity/activity/src/index.ts new file mode 100644 index 0000000000..4e3927abe5 --- /dev/null +++ b/packages/activity/activity/src/index.ts @@ -0,0 +1,138 @@ +/** + * The streaming-output observation Service Definition (`ctx.activities`). An + * activity is one observable unit of long-running work: an append-only bounded + * output stream plus live status, readable by any number of independent + * observers at absolute byte offsets. The registry is a pure observation + * plane — it starts nothing, cancels nothing, and is invisible to the model; + * producers keep their execution resources and their existing model-facing + * surfaces (`ctx.jobs` cursors, tool results). The process-local registry + * lives in `@deepseek-ai/dsh-activity-local`. + * @module @deepseek-ai/dsh-activity + */ + +import { Context, Service } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { + ActivitiesChangedListener, ActivityHandle, ActivityId, ActivityOpen, ActivityOutputListener, + ActivityRead, ActivitySnapshot, +} from './types.ts' + +export { ActivityId } from './brand.ts' +export type { + ActivityAppendOptions, + ActivityChannel, + ActivityCorrelation, + ActivitiesChangedListener, + ActivityHandle, + ActivityKind, + ActivityKindMap, + ActivityOpen, + ActivityOutcome, + ActivityOutputChunk, + ActivityOutputListener, + ActivityRead, + ActivitySnapshot, + ActivityStatus, +} from './types.ts' +export { pumpActivityOutput } from './pump.ts' +export type { ActivityPumpOptions, ActivityPumpRead, ActivityPumpSource } from './pump.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + activities: ActivityRegistry + } +} + +/** + * Abstract streaming-output registry. Subclass, implement the abstract + * methods, and load the subclass as a plugin — it registers as + * `ctx.activities` (one implementation per context; loading a second throws, + * which is cordis' standard duplicate-service behavior). + * + * Implementations must honor these semantics: + * - Observation never consumes. Any number of readers hold their own absolute + * byte offsets; a read changes no cursor and no producer state, so the + * model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. + * - Records outlive producer fibers. Owner disposal ends still-open records + * and removes them; service disposal ends and clears everything. Neither + * awaits a producer — the registry owns no execution resource. + * - Retention is bounded. Appends past the live cap drop the oldest retained + * bytes; a reader below the retained window gets a lossy read, never an + * error. Settlement trims retention to the settled cap. + * - Listener delivery is owner-relative: a listener registered from an + * unscoped context — a host composition's own carrier — sees every owner, + * while one registered under an agent composition's scope sees exactly the + * agents composed under it. Every listener is contained. + */ +export abstract class ActivityRegistry extends Service { + constructor(ctx: Context) { + // `abstract` erases at runtime, so a composition row naming this package + // would register a ctx.activities with no method implementations and fail + // far from the misconfiguration. Fail loud at load instead. + if (new.target === ActivityRegistry) { + throw new Error('@deepseek-ai/dsh-activity is the abstract activity observation seam; load an implementation such as @deepseek-ai/dsh-activity-local instead') + } + super(ctx, 'activities') + } + + /** + * Validate the spec, attach owner cleanup, and atomically register one + * activity. A rejection leaves no id; after return the producer owns the + * handle and the visible set has changed. + * @param spec - activity identity, owner, and correlation. + * @returns the producer face of the registered activity. + */ + abstract open(spec: ActivityOpen): ActivityHandle + + /** + * List caller-owned and unowned activities in registration order without + * exposing another session's labels or output. + * @param caller - reading agent; a non-agent caller sees only unowned activities. + * @returns fresh snapshots. + */ + abstract list(caller?: Agent): ActivitySnapshot[] + + /** + * Return a fresh snapshot. Throws for an unknown or foreign activity. + * @param id - activity to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ + abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot + + /** + * Read retained output from an absolute byte offset without consuming it. + * Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Throws for an unknown or foreign activity or a negative or non-integer + * offset. + * @param id - activity to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ + abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead + + /** + * Register an effect-scoped observer of visible-set changes: opening, + * detail updates, settlement, owner-disposal removal, and the emptying that + * service disposal commits — so an observer re-reads rather than + * accumulating deltas. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned activity changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ + abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void + + /** + * Register an effect-scoped observer of stream advancement — one signal per + * committed append and one at settlement, carrying only the activity id. + * A consumer schedules a {@link read} from its own cursor; the registry + * never pushes payloads. Listeners are contained and never awaited. + * @param listener - receives the id whose stream advanced. + * @returns disposer that unregisters the listener. + */ + abstract onOutput(listener: ActivityOutputListener): () => void +} + +export default ActivityRegistry diff --git a/packages/activity/activity/src/invariant.ts b/packages/activity/activity/src/invariant.ts new file mode 100644 index 0000000000..6753fa7b0e --- /dev/null +++ b/packages/activity/activity/src/invariant.ts @@ -0,0 +1,62 @@ +/** Package-owned process-snapshot invariants. @module @deepseek-ai/dsh-activity/invariant */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ActivitySnapshot } from './types.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-activity' +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed']) + +/** Cordis companion plugin name. */ +export const name = 'activity-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** Validate the cross-field relationships in one registry snapshot. */ +/* jscpd:ignore-start -- mirrors the jobs invariant's id/ordinal checks by design; the two registries share the `-N` scheme. */ +function validateSnapshot(snapshot: ActivitySnapshot, fail: InvariantFailure): void { + const id = String(snapshot.id) + const prefix = `${snapshot.kind}-` + const ordinal = Number(id.slice(prefix.length)) + if (snapshot.kind.length === 0 || !id.startsWith(prefix) + || !Number.isSafeInteger(ordinal) || ordinal < 1) { + fail(`activity snapshot id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`) + } + if (snapshot.label.length === 0) fail(`activity ${JSON.stringify(id)} label must be non-empty`) + if (!Number.isSafeInteger(snapshot.startedAt) || snapshot.startedAt < 0) { + fail(`activity ${JSON.stringify(id)} startedAt must be a non-negative epoch integer`) + } + /* jscpd:ignore-end */ + + const terminal = TERMINAL_STATUSES.has(snapshot.status) + if (terminal !== (snapshot.finishedAt !== undefined)) { + fail(`activity ${JSON.stringify(id)} finishedAt must be present exactly for a terminal status`) + } + if (snapshot.finishedAt !== undefined + && (!Number.isSafeInteger(snapshot.finishedAt) || snapshot.finishedAt < snapshot.startedAt)) { + fail(`activity ${JSON.stringify(id)} finishedAt must be an epoch integer no earlier than startedAt`) + } + + if (!Number.isSafeInteger(snapshot.outputTotal) || snapshot.outputTotal < 0 + || !Number.isSafeInteger(snapshot.outputEarliest) || snapshot.outputEarliest < 0 + || snapshot.outputEarliest > snapshot.outputTotal) { + fail(`activity ${JSON.stringify(id)} output offsets must satisfy 0 <= outputEarliest <= outputTotal`) + } +} + +/** Install checks over current visible records and every later visible-set change. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const snapshot of ctx.activities.list()) validateSnapshot(snapshot, fail) + ctx.activities.onActivitiesChanged((owner: Agent | undefined) => { + for (const snapshot of ctx.activities.list(owner)) validateSnapshot(snapshot, fail) + }) +}, { inject: ['activities'] }) + +/** + * Register the process-registry invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/activity/activity/src/pump.ts b/packages/activity/activity/src/pump.ts new file mode 100644 index 0000000000..4cc1eae22d --- /dev/null +++ b/packages/activity/activity/src/pump.ts @@ -0,0 +1,95 @@ +/** + * Shared poll pump for producers whose output substrate is a non-consuming + * offset reader (the subprocess `readFrom(fromByte)` family). Pure utility — + * no cordis, no timers retained past settlement. + * @module + */ + +import type { ActivityChannel, ActivityHandle } from './types.ts' + +/** One non-consuming offset read from a producer-owned stream. */ +export interface ActivityPumpRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean +} + +/** One producer-owned stream the pump copies into a process. */ +export interface ActivityPumpSource { + /** Stream label attached to every chunk this source yields. */ + channel?: ActivityChannel + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, and the lossy flag. + */ + read(fromByte: number): ActivityPumpRead +} + +/** Cadence and settlement for one {@link pumpActivityOutput} run. */ +export interface ActivityPumpOptions { + /** Poll interval in milliseconds; a positive finite number. */ + pollMs: number + /** + * Settles when the producer's work finished and its sources hold their + * final bytes. The pump drains once more after settlement and returns; + * a rejection is treated as settlement. + */ + done: Promise +} + +/** + * Copy producer streams into a process at a bounded cadence: drain every + * source, sleep `pollMs` or until `done` settles, repeat, then drain one + * final time. A lossy source read appends its surviving tail with + * `gapBefore`, so the discontinuity stays visible to observers. The pump + * never calls `handle.end()` — the producer maps its own outcome. + * @param handle - the open process receiving the copied chunks. + * @param sources - producer streams, each pumped at its own offset. + * @param options - poll cadence and the producer's settlement promise. + * @returns resolves after the final post-settlement drain. + */ +export async function pumpActivityOutput( + handle: ActivityHandle, + sources: readonly ActivityPumpSource[], + options: ActivityPumpOptions, +): Promise { + if (!Number.isFinite(options.pollMs) || options.pollMs <= 0) { + throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(options.pollMs)}`) + } + const states = sources.map(source => ({ source, cursor: 0 })) + const drain = (): void => { + for (const state of states) { + const { source } = state + const read = source.read(state.cursor) + state.cursor = read.nextOffset + if (read.text.length === 0) continue + if (source.channel === undefined && !read.lossy) { + handle.append(read.text) + } else { + handle.append(read.text, { + ...source.channel !== undefined ? { channel: source.channel } : {}, + ...read.lossy ? { gapBefore: true as const } : {}, + }) + } + } + } + let settled = false + const settlement = options.done.then(() => { settled = true }, () => { settled = true }) + while (!settled) { + drain() + await Promise.race([ + new Promise((resolve) => { + const timer = setTimeout(resolve, options.pollMs) + // The settlement branch wins the race; clear the loser so a finished + // pump holds no pending timer for up to one poll interval. + void settlement.then(() => { clearTimeout(timer); resolve() }) + }), + settlement, + ]) + } + drain() +} diff --git a/packages/activity/activity/src/types.ts b/packages/activity/activity/src/types.ts new file mode 100644 index 0000000000..fd748ec165 --- /dev/null +++ b/packages/activity/activity/src/types.ts @@ -0,0 +1,179 @@ +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { CallId } from '@deepseek-ai/dsh-llm/brand' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { ActivityId } from './brand.ts' + +export type { ActivityId } from './brand.ts' + +/** + * Producer-defined activity kinds. Plugins extend this map by declaration + * merging; the registry treats every value as an opaque id namespace. + */ +export interface ActivityKindMap { + bash: 'bash' +} + +/** A registered activity kind — also the id prefix (`bash`, `workflow`, …). */ +export type ActivityKind = ActivityKindMap[keyof ActivityKindMap] + +/** Lifecycle state of one observable activity. */ +export type ActivityStatus = 'running' | 'completed' | 'failed' | 'killed' + +/** + * Output stream label. Producers without distinct streams omit it; consumers + * treat an unrecognized label like an absent one. + */ +export type ActivityChannel = 'stdout' | 'stderr' + +/** + * Links one activity to the records other domains keep for the same work, so a + * consumer can attach the output stream to an existing card or row without + * guessing from labels. + */ +export interface ActivityCorrelation { + /** Tool call that started the work, when a tool produced it. */ + callId?: CallId + /** Background job carrying the model-facing control surface, when one exists. */ + jobId?: JobId +} + +/** + * Producer declaration passed to {@link ActivityRegistry.open}. The registry + * owns identity, retention, and observer delivery; the producer keeps its + * execution resources and reports through the returned {@link ActivityHandle}. + */ +export interface ActivityOpen { + /** Producer kind — also the id prefix. */ + kind: ActivityKind + /** One-line human-facing label (the command; the run description). */ + label: string + /** + * Owning live agent. Visibility is fenced by its session id, and agent + * disposal ends and removes the record. The instance must be the one + * currently registered under its agent id. Omitting the owner creates an + * unowned activity, visible to any caller until service disposal. + */ + owner?: Agent + /** Optional links to the tool call and background job for the same work. */ + correlation?: ActivityCorrelation +} + +/** Terminal outcome reported through {@link ActivityHandle.end}. */ +export interface ActivityOutcome { + /** How the work ended: finished (`completed`), broke (`failed`), or was cancelled (`killed`). */ + status: 'completed' | 'failed' | 'killed' + /** Kind-specific detail rendered into status lines ('exit code: 3'). */ + detail?: string +} + +/** Options for one {@link ActivityHandle.append}. */ +export interface ActivityAppendOptions { + /** Stream label for the chunk; omitted for producers without distinct streams. */ + channel?: ActivityChannel + /** + * Marks that producer-side bytes between the previous chunk and this one + * were lost (a source window slid before the producer could copy it), so a + * consumer can render the discontinuity instead of a silent splice. + */ + gapBefore?: true +} + +/** + * Producer face of one open activity. All methods are synchronous. After + * {@link end} — including a registry-forced end during owner or service + * teardown — `append` and `updateDetail` log and drop instead of throwing, so + * a producer's trailing flush cannot break its own teardown path. + */ +export interface ActivityHandle { + /** The registry-issued id (`-N`). */ + readonly id: ActivityId + /** + * Append one output chunk. Offsets advance by the chunk's UTF-8 byte + * length; an empty chunk is dropped without waking observers. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: ActivityAppendOptions): void + /** + * Replace the snapshot's status detail (a progress line such as `3/10`). + * @param detail - the new detail line. + */ + updateDetail(detail: string): void + /** + * Record the terminal outcome and trim retention to the settled cap. + * First-wins: a later call — the producer's own report after a teardown + * force-end, or a duplicate — is dropped silently. + * @param outcome - terminal status and optional detail. + */ + end(outcome: ActivityOutcome): void +} + +/** + * A read-only projection of one activity, safe to hand to listeners and + * consumers — a fresh object per call, never live registry state. + */ +export interface ActivitySnapshot { + /** The registry-issued id (`-N`). */ + id: ActivityId + /** The producer kind the process was opened with. */ + kind: ActivityKind + /** The producer-supplied one-line label. */ + label: string + /** Owner session id used for visibility and grouping; absent for unowned activities. */ + ownerSession?: SessionId + /** Links to the tool call and background job for the same work, when supplied. */ + correlation?: ActivityCorrelation + /** Current lifecycle state. */ + status: ActivityStatus + /** Kind-specific status detail, when the producer supplied one. */ + detail?: string + /** Epoch ms when the activity was opened. */ + startedAt: number + /** Epoch ms when the activity ended; absent while `running`. */ + finishedAt?: number + /** Total UTF-8 bytes ever appended — the offset the next chunk starts at. */ + outputTotal: number + /** + * Offset of the oldest retained byte. Greater than zero exactly when + * retention dropped the head; a reader starting below it gets a lossy read. + */ + outputEarliest: number +} + +/** One retained output chunk returned by {@link ActivityRegistry.read}. */ +export interface ActivityOutputChunk { + /** Absolute offset of the chunk's first byte. */ + at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + text: string + /** Stream label, when the producer supplied one. */ + channel?: ActivityChannel + /** Producer-reported loss immediately before this chunk. */ + gapBefore?: true +} + +/** Result of one non-consuming {@link ActivityRegistry.read}. */ +export interface ActivityRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly ActivityOutputChunk[] + /** Offset to resume from — the process' current `outputTotal`. */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean +} + +/** + * Observer of visible-set changes: opening, detail updates, settlement, and + * removal. Receives the owner whose set changed, or `undefined` when an + * unowned process changed and every caller's set did. Consumers re-read + * rather than accumulating deltas. + */ +export type ActivitiesChangedListener = (owner: Agent | undefined) => void + +/** + * Observer of stream advancement for one activity: new appended output, or the + * settlement that ends the stream. Carries no payload — a consumer schedules a + * {@link ActivityRegistry.read} from its own cursor. + */ +export type ActivityOutputListener = (id: ActivityId) => void diff --git a/packages/activity/activity/tests/invariant.spec.ts b/packages/activity/activity/tests/invariant.spec.ts new file mode 100644 index 0000000000..306cd4533a --- /dev/null +++ b/packages/activity/activity/tests/invariant.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import ActivityRegistry, { ActivityId } from '@deepseek-ai/dsh-activity' +import type { ActivitiesChangedListener, ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import * as ProcessInvariant from '@deepseek-ai/dsh-activity/invariant' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' + +const BASE: ActivitySnapshot = { + id: ActivityId('bash-1'), + kind: 'bash', + label: 'compile', + status: 'completed', + startedAt: 10, + finishedAt: 20, + outputTotal: 12, + outputEarliest: 4, +} + +const RUNNING: ActivitySnapshot = { + id: ActivityId('bash-1'), + kind: 'bash', + label: 'compile', + status: 'running', + startedAt: 10, + outputTotal: 0, + outputEarliest: 0, +} + +/** Boot the companion over a probe registry whose visible set the test controls. */ +async function setup(seed: ActivitySnapshot[] = []): Promise<(visible: ActivitySnapshot[], owner?: Agent) => void> { + const ctx = new Context() + let current = seed + let listener: ActivitiesChangedListener | undefined + const probe = { + list: () => current, + onActivitiesChanged(value: ActivitiesChangedListener) { + listener = value + return () => { listener = undefined } + }, + } as unknown as ActivityRegistry + await ctx.plugin(InvariantRegistry) + await ctx.plugin({ + name: 'activity-invariant-probe', + apply(child: Context) { child.provide('activities', probe) }, + }) + await ctx.plugin(ProcessInvariant) + if (listener === undefined) throw new Error('activity invariant did not subscribe to visible-set changes') + return (visible, owner) => { + current = visible + listener!(owner) + } +} + +describe('process-registry invariants', () => { + it('accepts coherent running and terminal snapshots', async () => { + const notify = await setup([RUNNING]) + expect(() => { notify([BASE]) }).not.toThrow() + expect(() => { notify([{ ...BASE, id: ActivityId('workflow-2'), kind: 'workflow' }]) }) + .not.toThrow() + }) + + it.each([ + [{ ...BASE, id: ActivityId('-1'), kind: '' as ActivitySnapshot['kind'] }, /positive ordinal/], + [{ ...BASE, id: ActivityId('other-1') }, /must be "bash-" followed by a positive ordinal/], + [{ ...BASE, id: ActivityId('bash-0') }, /positive ordinal/], + [{ ...BASE, label: '' }, /label must be non-empty/], + [{ ...BASE, startedAt: -1 }, /startedAt must be a non-negative epoch integer/], + [{ ...BASE, status: 'running' as const, finishedAt: 20 }, /finishedAt must be present exactly for a terminal status/], + [{ ...RUNNING, status: 'killed' as const }, /finishedAt must be present exactly for a terminal status/], + [{ ...BASE, finishedAt: 9 }, /no earlier than startedAt/], + [{ ...BASE, outputEarliest: 13 }, /0 <= outputEarliest <= outputTotal/], + [{ ...BASE, outputTotal: -1, outputEarliest: 0 }, /0 <= outputEarliest <= outputTotal/], + ] as const)('rejects an incoherent registry snapshot', async (snapshot, message) => { + const notify = await setup() + expect(() => { notify([snapshot]) }).toThrow(message) + }) + + it('rejects an incoherent record already present at installation', async () => { + await expect(setup([{ ...BASE, label: '' }])).rejects.toThrow(/label must be non-empty/) + }) +}) diff --git a/packages/activity/activity/tests/service.spec.ts b/packages/activity/activity/tests/service.spec.ts new file mode 100644 index 0000000000..2dd685c1d3 --- /dev/null +++ b/packages/activity/activity/tests/service.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import ActivityRegistry, { ActivityId, pumpActivityOutput } from '@deepseek-ai/dsh-activity' +import type { ActivityAppendOptions, ActivityHandle, ActivityPumpSource, ActivitySnapshot } from '@deepseek-ai/dsh-activity' + +/** Record every append so pump behavior is observable without a registry. */ +function recordingHandle(): { handle: ActivityHandle; appends: { text: string; options?: ActivityAppendOptions }[] } { + const appends: { text: string; options?: ActivityAppendOptions }[] = [] + const handle: ActivityHandle = { + id: ActivityId('bash-1'), + append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) }, + updateDetail() {}, + end() {}, + } + return { handle, appends } +} + +/** A scripted source: each read() shifts the next scripted result. */ +function scriptedSource( + reads: { text: string; lossy?: boolean }[], + channel?: 'stdout' | 'stderr', +): { source: ActivityPumpSource; offsets: number[] } { + const offsets: number[] = [] + let offset = 0 + const source: ActivityPumpSource = { + ...channel !== undefined ? { channel } : {}, + read(fromByte) { + offsets.push(fromByte) + const next = reads.shift() ?? { text: '' } + offset += Buffer.byteLength(next.text, 'utf8') + return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } + }, + } + return { source, offsets } +} + +describe('ActivityRegistry seam', () => { + it('refuses to mount the abstract Service Definition directly', async () => { + const ctx = new Context() + await expect(ctx.plugin(ActivityRegistry as unknown as () => void)) + .rejects.toThrow(/load an implementation such as @deepseek-ai\/dsh-activity-local/) + }) + + it('preserves the SessionId brand on public owner snapshots', () => { + expectTypeOf().toEqualTypeOf() + }) +}) + +describe('pumpActivityOutput', () => { + it('rejects a non-positive poll interval before touching any source', async () => { + const { handle } = recordingHandle() + await expect(pumpActivityOutput(handle, [], { pollMs: 0, done: Promise.resolve() })) + .rejects.toThrow(/invalid pump pollMs/) + }) + + it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => { + vi.useFakeTimers() + try { + const { handle, appends } = recordingHandle() + let settle!: () => void + const done = new Promise((resolve) => { settle = resolve }) + const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') + const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr') + const pump = pumpActivityOutput(handle, [out.source, err.source], { pollMs: 50, done }) + + await vi.advanceTimersByTimeAsync(50) + settle() + await pump + expect(appends).toEqual([ + { text: 'a', options: { channel: 'stdout' } }, + { text: 'bc', options: { channel: 'stdout' } }, + { text: 'E', options: { channel: 'stderr' } }, + ]) + // Every read resumed from the previous nextOffset, never from a shared cursor. + expect(out.offsets).toEqual([0, 1, 3]) + expect(err.offsets).toEqual([0, 0, 1]) + // Settlement cleared the raced poll timer instead of leaving it pending. + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('marks a lossy source read as a gap so observers see the discontinuity', async () => { + const { handle, appends } = recordingHandle() + const { source } = scriptedSource([{ text: 'tail', lossy: true }]) + await pumpActivityOutput(handle, [source], { pollMs: 1, done: Promise.resolve() }) + expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }]) + }) + + it('treats a rejected done as settlement and still drains the final bytes', async () => { + const { handle, appends } = recordingHandle() + const { source } = scriptedSource([{ text: 'last' }]) + await pumpActivityOutput(handle, [source], { pollMs: 1, done: Promise.reject(new Error('producer broke')) }) + expect(appends).toEqual([{ text: 'last' }]) + }) +}) diff --git a/packages/activity/activity/tsconfig.json b/packages/activity/activity/tsconfig.json new file mode 100644 index 0000000000..f4deee6327 --- /dev/null +++ b/packages/activity/activity/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../jobs/jobs" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/api/activity-controller/README.i18n.yaml b/packages/api/activity-controller/README.i18n.yaml new file mode 100644 index 0000000000..e49b392e32 --- /dev/null +++ b/packages/api/activity-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/activity-controller/README.md +README.md: 10885ec087cc295df6bcb033480373db04938097 +README.zh.md: 9509837baa4661209c7d826bee149b76a11f4723 diff --git a/packages/api/activity-controller/README.md b/packages/api/activity-controller/README.md new file mode 100644 index 0000000000..10885ec087 --- /dev/null +++ b/packages/api/activity-controller/README.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-api-activity-controller + +English | [中文](README.zh.md) + +Activity Remote streams over the optional `ctx.activities` registry, plus their React-free client model. A pure read surface: it starts nothing, cancels nothing, and never touches the model-facing consuming cursors — the wire path reads only the registry's non-consuming offsets. + +The Host `ActivityController` (namespace `activity`) exposes two `@Remote({ mode: 'stream' })` methods: + +- `control(signal)` — one complete roster baseline, then whole-bucket replacement frames per owner session on every registry change (the self-healing shape the session control stream uses for jobs). A composition without the registry serves an empty baseline. +- `observe({ activityId, from? }, signal)` — one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget; one larger chunk ships whole), then one terminal `status` after the settled activity is drained, after which the generation closes normally. Reconnecting callers resume by passing the last frame's `next`; a resume behind the retained window arrives flagged `lossy`. Status rides the same stream as output, so settlement can never race a still-open output channel. + +| Config | Default | Meaning | +|---|---|---| +| `flushMs` | `100` | Coalescing window after new output before an observation read. | +| `maxFrameBytes` | `65536` | Soft byte budget per observation output frame. | + +The client half (`./client`) installs `ctx.activityFeed`: `ClientActivityModel` mirrors the roster (last-wins buckets, generation replace) and accumulates each observed activity's bounded render tail, and `ClientActivityFeed.observe(id)` opens reference-counted observation streams that resume from the model's cursor across carrier generations. + +## Model Experience + +None, as activity transport is browser and Host observation state and registers no prompt, tool, or session event. + +#### KV Cache effect + +None; the package never assembles or alters provider requests. + +## Known Limitations and Deferred Work + +- **Registry presence is sampled at construction** — a registry mounted after the controller stays unobserved until the controller reloads. +- **The roster names no watermark** — an observation stream anchors itself; correlating roster rows against observation offsets is client policy. diff --git a/packages/api/activity-controller/README.zh.md b/packages/api/activity-controller/README.zh.md new file mode 100644 index 0000000000..9509837baa --- /dev/null +++ b/packages/api/activity-controller/README.zh.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-api-activity-controller + +[English](README.md) | 中文 + +架在可选 `ctx.activities` 注册表之上的 Activity Remote 流,附 React-free 客户端模型。纯读面:不启动、不取消任何工作,也绝不触碰模型侧消费游标——wire 路径只读注册表的非消费 offset。 + +宿主 `ActivityController`(命名空间 `activity`)暴露两个 `@Remote({ mode: 'stream' })` 方法: + +- `control(signal)`——一份完整 roster baseline,之后每次注册表变化按 owner 会话推送整桶替换帧(与 session control 流承载 jobs 的自愈形态一致)。没有注册表的组合返回空 baseline。 +- `observe({ activityId, from? }, signal)`——一帧 `opened` 锚点,随后是合并的 `output` 帧(`flushMs` 窗口、`maxFrameBytes` 软预算;更大的单 chunk 整帧发出),activity 结算且排干后发一帧终态 `status`,然后本代正常关闭。重连方以上一帧的 `next` 作为 `from` 续传;落后于保留窗口的续传会带 `lossy` 标记。状态与输出同流,结算永远不会与仍开着的输出通道竞态。 + +| 配置 | 默认值 | 含义 | +|---|---|---| +| `flushMs` | `100` | 新输出到达后、观察读取前的合并窗口。 | +| `maxFrameBytes` | `65536` | 每个观察输出帧的软字节预算。 | + +客户端半区(`./client`)安装 `ctx.activityFeed`:`ClientActivityModel` 镜像 roster(last-wins 分桶、按代整体替换)并为每个被观察 activity 累积有界渲染尾巴;`ClientActivityFeed.observe(id)` 打开按引用计数共享的观察流,跨 carrier 代次从模型游标续传。 + +## 模型体验 + +无:activity 传输是浏览器与宿主的观察状态,不注册任何 prompt、工具或会话事件。 + +#### KV Cache 影响 + +无;本包从不组装或改动 provider 请求。 + +## 已知限制与暂缓事项 + +- **注册表存在性在构造时采样**——控制器之后才挂载的注册表在控制器重载前不会被观察。 +- **roster 不携带水位**——观察流自带锚点;roster 行与观察 offset 的对齐是客户端策略。 diff --git a/packages/api/activity-controller/package.json b/packages/api/activity-controller/package.json new file mode 100644 index 0000000000..6898cff505 --- /dev/null +++ b/packages/api/activity-controller/package.json @@ -0,0 +1,103 @@ +{ + "name": "@deepseek-ai/dsh-api-activity-controller", + "description": "Activity Remote streams: the live-activity roster and per-activity non-consuming output observation", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/activity-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-activity-local": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + } +} diff --git a/packages/api/activity-controller/src/client/index.ts b/packages/api/activity-controller/src/client/index.ts new file mode 100644 index 0000000000..7338ae3b9b --- /dev/null +++ b/packages/api/activity-controller/src/client/index.ts @@ -0,0 +1,86 @@ +/** Activity-specific adapter for the Gateway-owned snapshot stream lifecycle. */ + +import type { Context } from '@deepseek-ai/cordis' +import { + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { ActivityControlFrame } from '../types.ts' +import { ClientActivityModel } from './model.ts' +import { ClientActivityFeed } from './service.ts' +import type { ActivityRemote } from './service.ts' + +export type { ActivityFeedPhase, ActivityFeedSnapshot, ObservedActivity } from './model.ts' +export type { ActivityFeedSource, ActivityRemote, IActivityFeed } from './service.ts' +export type { ActivityId } from '@deepseek-ai/dsh-activity/brand' +export type { ActivityRow } from '../types.ts' + +type ActivityStreamRemote = Pick & { readonly activity: ActivityRemote } + +type ActivityBaselineFrame = Extract +type ActivityRowsFrame = Extract + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free client activity roster and observation control. */ + activityFeed: import('./service.ts').IActivityFeed + } +} + +/** Required Client Remote services. */ +export const inject = ['remote', 'remote.activity'] + +/** + * Install the client activity model, its service face, and the reconnecting + * roster stream. + * @param ctx - client root Context. + */ +export function apply(ctx: Context): void { + const raw = ctx.remote as ActivityStreamRemote + // Resolve the child namespace to a concrete value while this plugin's + // context is current: later stream (re)opens run on caller stacks (a React + // event, a carrier retry) whose dynamic context has not declared + // `remote.activity`, and a property access there fails the inject check. + const remote: ActivityStreamRemote = { + $stream: options => raw.$stream(options), + activity: raw.activity, + } + const model = new ClientActivityModel() + new ClientActivityFeed(ctx, remote, model) + const control = createActivityRosterStream(remote, model) + control.start() + ctx.effect( + () => async () => { await control.dispose() }, + 'activity-controller.client.control', + ) +} + +/** + * Create the reconnecting roster stream feeding one client model. + * @param remote - generated activity namespace and Gateway stream factory. + * @param model - roster sink. + * @returns an unstarted stream owned by the client activity runtime. + */ +export function createActivityRosterStream( + remote: ActivityStreamRemote, + model: ClientActivityModel, +): RemoteSnapshotStream { + const stream = remote.$stream({ + name: 'activity roster stream', + open: signal => remote.activity.control(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('activity roster stream ended without a terminal result') + : new Error('activity roster stream ended before its opening baseline'), + }) + return new RemoteSnapshotStream(stream, { + name: 'activity roster stream', + isSnapshot: (frame): frame is ActivityBaselineFrame => frame.type === 'baseline', + replace: (frame) => { model.replaceBaseline(frame.activities) }, + update: (frame) => { model.replaceBucket(frame.sessionId, frame.activities) }, + failed: () => { + // The previous roster stays published while the stream retries; a + // terminal protocol failure leaves the last snapshot in place. + }, + }) +} diff --git a/packages/api/activity-controller/src/client/model.ts b/packages/api/activity-controller/src/client/model.ts new file mode 100644 index 0000000000..96fb7cca5a --- /dev/null +++ b/packages/api/activity-controller/src/client/model.ts @@ -0,0 +1,211 @@ +/** Client-side activity state model shared by Remote transport and UI projection. */ + +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' +import type { ActivityObserveFrame, ActivityRow } from '../types.ts' + +/** Bounded per-activity render tail, in UTF-16 code units. */ +const RENDER_TAIL_LIMIT = 128 * 1024 + +/** Roster arrival lifecycle: `pending` until the first baseline lands. */ +export type ActivityFeedPhase = 'pending' | 'ready' + +/** One observed activity's live view state. */ +export interface ObservedActivity { + readonly activityId: ActivityId + /** Accumulated output tail, bounded to the render limit. */ + readonly text: string + /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ + readonly gapBefore: boolean + /** Latest status delivered on the observation stream. */ + readonly status: ActivityRow['status'] + readonly detail?: string + /** True while the observation stream is open and the activity has not settled. */ + readonly streaming: boolean + /** Terminal observation failure, when the stream ended abnormally. */ + readonly error?: string +} + +/** Immutable client activity state. */ +export interface ActivityFeedSnapshot { + /** Roster rows per owner session; the empty key holds unowned activities. */ + readonly rowsBySession: Readonly> + /** Live observation state keyed by activity id. */ + readonly observed: Readonly> + readonly phase: ActivityFeedPhase +} + +const UNOWNED = '' + +/** Mutable observation bookkeeping behind one {@link ObservedActivity} view. */ +interface ObservedState { + view: ObservedActivity + /** Resume offset for the next generation's `from`. */ + cursor: number | undefined +} + +/** + * Owns the client roster mirror and per-activity observation state. Pure data + * plus subscriptions — transport wiring stays in the client plugin, UI stays + * in slot components. + */ +export class ClientActivityModel { + private rowsBySession: Readonly> = {} + private readonly observedStates = new Map() + private phase: ActivityFeedPhase = 'pending' + private readonly listeners = new Set<() => void>() + private snapshotCache: ActivityFeedSnapshot = { rowsBySession: {}, observed: {}, phase: 'pending' } + private snapshotDirty = false + + /** + * Read the identity-stable current snapshot. + * @returns the cached snapshot, rebuilt only after a change. + */ + getSnapshot(): ActivityFeedSnapshot { + if (this.snapshotDirty) { + const observed: Record = {} + for (const [id, state] of this.observedStates) observed[id] = state.view + this.snapshotCache = { rowsBySession: this.rowsBySession, observed, phase: this.phase } + this.snapshotDirty = false + } + return this.snapshotCache + } + + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Replace the whole roster from a generation baseline. + * @param rows - every visible row across all owner buckets. + */ + replaceBaseline(rows: readonly ActivityRow[]): void { + const next: Record = {} + for (const row of rows) { + const key = row.sessionId ?? UNOWNED + ;(next[key] ??= []).push(row) + } + this.rowsBySession = next + this.phase = 'ready' + this.changed() + } + + /** + * Replace one owner bucket wholesale; an empty replacement drops the key. + * @param sessionId - owner session, or undefined for the unowned bucket. + * @param rows - the bucket's complete visible rows. + */ + replaceBucket(sessionId: string | undefined, rows: readonly ActivityRow[]): void { + const key = sessionId ?? UNOWNED + if (rows.length === 0) { + this.rowsBySession = Object.fromEntries( + Object.entries(this.rowsBySession).filter(([bucket]) => bucket !== key)) + } else { + this.rowsBySession = { ...this.rowsBySession, [key]: rows } + } + this.changed() + } + + /** + * The resume offset for one activity's next observation generation. + * @param id - observed activity. + * @returns the last accepted `next`, or undefined for a fresh observation. + */ + cursorOf(id: ActivityId): number | undefined { + return this.observedStates.get(String(id))?.cursor + } + + /** + * Install or reset observation state when a generation's anchor arrives. + * @param id - observed activity. + * @param frame - the generation's `opened` anchor. + */ + observeOpened(id: ActivityId, frame: Extract): void { + const existing = this.observedStates.get(String(id)) + const view: ObservedActivity = { + activityId: id, + text: existing?.view.text ?? '', + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: true, + } + this.observedStates.set(String(id), { view, cursor: frame.from }) + this.changed() + } + + /** + * Append one output frame's chunks to the bounded render tail. + * @param id - observed activity. + * @param frame - a coalesced `output` frame. + */ + observeOutput(id: ActivityId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') + let gapBefore = state.view.gapBefore || frame.lossy === true + || frame.chunks.some(chunk => chunk.gapBefore === true) + if (text.length > RENDER_TAIL_LIMIT) { + let cut = text.length - RENDER_TAIL_LIMIT + // Never split a surrogate pair at the render bound. + const unit = text.charCodeAt(cut) + if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 + text = text.slice(cut) + gapBefore = true + } + state.view = { ...state.view, text, gapBefore } + state.cursor = frame.next + this.changed() + } + + /** + * Record the terminal status delivered at the end of the observation stream. + * @param id - observed activity. + * @param frame - the terminal `status` frame. + */ + observeStatus(id: ActivityId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + state.view = { + ...state.view, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: false, + } + this.changed() + } + + /** + * Record a terminal observation failure. + * @param id - observed activity. + * @param error - the stream's terminal failure. + */ + observeFailed(id: ActivityId, error: unknown): void { + const state = this.observedStates.get(String(id)) + if (state === undefined) return + state.view = { ...state.view, streaming: false, error: String(error) } + this.changed() + } + + /** + * Drop observation state after the last observer stops. + * @param id - the no-longer-observed activity. + */ + observeStopped(id: ActivityId): void { + if (!this.observedStates.delete(String(id))) return + this.changed() + } + + private changed(): void { + this.snapshotDirty = true + notifySubscribers(this.listeners, 'activity feed') + } +} diff --git a/packages/api/activity-controller/src/client/service.ts b/packages/api/activity-controller/src/client/service.ts new file mode 100644 index 0000000000..a640bc8ba2 --- /dev/null +++ b/packages/api/activity-controller/src/client/service.ts @@ -0,0 +1,156 @@ +/** React-free client activity service: roster snapshot plus observation control. */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-activity-controller/remote' +import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' +import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' +import type { ActivityObserveFrame } from '../types.ts' +import type { ActivityFeedSnapshot, ClientActivityModel } from './model.ts' + +/** Complete generated `ctx.remote.activity` namespace. */ +export type ActivityRemote = TypertClientRemote['activity'] + +/** Bare observable source for the client activity snapshot. */ +export interface ActivityFeedSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): ActivityFeedSnapshot + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void +} + +/** The activity feed's client service face. */ +export interface IActivityFeed { + /** Roster rows, observation state, and arrival phase. */ + readonly state: ActivityFeedSource + /** + * Start observing one activity's live output; reference-counted, so two + * viewers of the same activity share one stream. + * @param id - activity to observe. + * @returns stop function releasing this observer's reference. + */ + observe(id: ActivityId): () => void +} + +/** One reference-counted observation stream. */ +interface ObservationEntry { + refs: number + stopped: boolean + dispose: () => Promise +} + +/** Remote face the observation runner drives. */ +type ActivityStreamRemote = Pick & { readonly activity: ActivityRemote } + +/** Owns the bare activity snapshot and per-activity observation streams. */ +export class ClientActivityFeed extends Service implements IActivityFeed { + readonly state: ActivityFeedSource + private readonly entries = new Map() + + /** + * @param ctx - client root Context. + * @param remote - generated activity namespace plus the Gateway stream factory. + * @param model - shared client activity model. + */ + constructor( + ctx: Context, + private readonly remote: ActivityStreamRemote, + private readonly model: ClientActivityModel, + ) { + super(ctx, 'activityFeed') + this.state = model + ctx.effect(() => () => { + const open = [...this.entries.values()] + this.entries.clear() + for (const entry of open) { + entry.stopped = true + void entry.dispose() + } + }, 'activity-controller.client.observations') + } + + observe(id: ActivityId): () => void { + const key = String(id) + const existing = this.entries.get(key) + if (existing !== undefined && !existing.stopped) { + existing.refs += 1 + return this.releaser(key) + } + const entry = this.startObservation(id) + this.entries.set(key, entry) + return this.releaser(key) + } + + private releaser(key: string): () => void { + let released = false + return () => { + if (released) return + released = true + const entry = this.entries.get(key) + if (entry === undefined) return + entry.refs -= 1 + if (entry.refs > 0) return + this.entries.delete(key) + entry.stopped = true + void entry.dispose().then(() => { + // The key is the stringified branded id this releaser was minted for. + this.model.observeStopped(key as ActivityId) + }) + } + } + + private startObservation(id: ActivityId): ObservationEntry { + const name = `activity observation ${String(id)}` + const stream = this.remote.$stream({ + name, + open: (signal) => { + const from = this.model.cursorOf(id) + return this.remote.activity.observe( + { activityId: id, ...from !== undefined ? { from } : {} }, + signal, + ) + }, + // A premature end after the anchor is retryable (a Host reload closes the + // generation); resuming from the cursor loses nothing. An end before the + // anchor is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before settlement`) + : new Error(`${name} ended before its anchor`), + }) + const entry: ObservationEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + const frame = item.value + if (frame.type === 'opened') { + this.model.observeOpened(id, frame) + item.accept() + continue + } + if (frame.type === 'output') { + this.model.observeOutput(id, frame) + continue + } + // Terminal status: leave the loop before the generation end is + // classified, then close the stream for good. + this.model.observeStatus(id, frame) + break + } + } catch (error) { + if (!entry.stopped) this.model.observeFailed(id, error) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } +} diff --git a/packages/api/activity-controller/src/feed.ts b/packages/api/activity-controller/src/feed.ts new file mode 100644 index 0000000000..34091dad8d --- /dev/null +++ b/packages/api/activity-controller/src/feed.ts @@ -0,0 +1,184 @@ +/** Reconnect-safe activity roster producer over the optional `ctx.activities` registry. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ActivityRegistry, ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import type { ActivityControlFrame, ActivityRow } from './types.ts' + +/** + * Project one registry snapshot into its browser-safe roster row. + * @param snapshot - fresh registry snapshot. + * @returns detached wire row for Remote consumers. + */ +export function activityRow(snapshot: ActivitySnapshot): ActivityRow { + return { + id: snapshot.id, + kind: snapshot.kind, + label: snapshot.label, + ...snapshot.ownerSession !== undefined ? { sessionId: snapshot.ownerSession } : {}, + ...snapshot.correlation !== undefined + ? { + correlation: { + ...snapshot.correlation.callId !== undefined ? { callId: snapshot.correlation.callId } : {}, + ...snapshot.correlation.jobId !== undefined ? { jobId: snapshot.correlation.jobId } : {}, + }, + } + : {}, + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + startedAt: snapshot.startedAt, + ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, + outputTotal: snapshot.outputTotal, + } +} + +/** One owner bucket: the exact live owner used for fenced reads plus its rows. */ +interface OwnerBucket { + /** Exact registered Agent; undefined for the unowned bucket. */ + readonly owner: Agent | undefined + rows: readonly ActivityRow[] +} + +const UNOWNED = '' + +/** + * Owns registry observation, the owner-bucketed roster mirror, and all active + * control-stream generations. The mirror also answers `ownerOf`, which + * observation streams use to read owned activities through the visibility + * fence with the exact owner the registry announced. + */ +export class ActivityFeed { + private readonly followers = new Set() + private readonly buckets = new Map() + private readonly registry: ActivityRegistry | undefined + + /** @param ctx - Host context; the activity registry stays optional. */ + constructor(ctx: Context) { + this.registry = ctx.get('activities') + if (this.registry === undefined) return + const registry = this.registry + this.refresh(undefined) + for (const agent of ctx.get('agents')?.list() ?? []) this.refresh(agent) + registry.onActivitiesChanged((owner) => { + this.refresh(owner) + const bucket = this.buckets.get(owner?.id ?? UNOWNED) + this.publish({ + type: 'rows', + ...owner !== undefined ? { sessionId: owner.id } : {}, + activities: bucket?.rows ?? [], + }) + }) + ctx.effect(() => () => { + for (const follower of this.followers) follower.close() + this.followers.clear() + }, 'activity-controller.feed') + } + + /** + * Read the complete current roster synchronously. + * @returns every visible row across all owner buckets. + */ + baseline(): readonly ActivityRow[] { + return [...this.buckets.values()].flatMap(bucket => bucket.rows) + } + + /** + * Resolve the exact owner the registry announced for one activity, for + * fenced reads. `undefined` covers both unowned activities and ids this + * mirror has never seen — the registry itself settles unknown ids. + * @param id - activity id to resolve. + * @returns the exact live owner, or undefined. + */ + ownerOf(id: string): Agent | undefined { + for (const bucket of this.buckets.values()) { + if (bucket.rows.some(row => String(row.id) === id)) return bucket.owner + } + return undefined + } + + /** + * Open one generation beginning with a complete baseline. + * @param signal - generation cancellation. + * @returns baseline followed by whole-bucket replacement frames. + */ + async *follow(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const follower = new ActivityFollower() + this.followers.add(follower) + try { + yield { type: 'baseline', activities: this.baseline() } + yield* follower.read(signal) + } finally { + this.followers.delete(follower) + follower.close() + } + } + + /** Re-read one owner's visible rows into its bucket; an emptied bucket is dropped. */ + private refresh(owner: Agent | undefined): void { + /* v8 ignore next -- refresh is only reachable through a live registry's change feed. */ + if (this.registry === undefined) return + const rows = this.registry.list(owner) + .filter(snapshot => owner === undefined + ? snapshot.ownerSession === undefined + : snapshot.ownerSession === owner.id) + .map(activityRow) + const key = owner?.id ?? UNOWNED + if (rows.length === 0) { + this.buckets.delete(key) + return + } + this.buckets.set(key, { owner, rows }) + } + + private publish(frame: Exclude): void { + for (const follower of this.followers) follower.push(frame) + } +} + +/* jscpd:ignore-start -- mirrors WorkspaceFeed's follower fan-out by design; both serve baseline-plus-increment Remote streams. */ +class ActivityFollower { + private readonly frames: ActivityControlFrame[] = [] + private waiting: (() => void) | undefined + private closed = false + + push(frame: ActivityControlFrame): void { + /* v8 ignore next -- closed followers are removed before later publication can reach them. */ + if (this.closed) return + this.frames.push(frame) + this.waiting?.() + } + + close(): void { + if (this.closed) return + this.closed = true + this.waiting?.() + } + + async *read(signal: AbortSignal): AsyncIterable { + while (!this.closed && !signal.aborted) { + const frame = this.frames.shift() + if (frame !== undefined) { + yield frame + continue + } + await this.wait(signal) + } + } + + private wait(signal: AbortSignal): Promise { + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one read owns the sole installed wait callback. */ + if (this.waiting === finish) this.waiting = undefined + resolve() + } + this.waiting = finish + signal.addEventListener('abort', finish, { once: true }) + /* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */ + if (signal.aborted || this.closed || this.frames.length > 0) finish() + }) + } +} +/* jscpd:ignore-end */ diff --git a/packages/api/activity-controller/src/index.ts b/packages/api/activity-controller/src/index.ts new file mode 100644 index 0000000000..a2d62c4a33 --- /dev/null +++ b/packages/api/activity-controller/src/index.ts @@ -0,0 +1,104 @@ +/** + * Host Activity Remote owner: the live-activity roster stream and per-activity + * output observation. A pure read surface over the optional `ctx.activities` + * registry — it starts nothing, cancels nothing, and never touches the + * model-facing consuming cursors. Compositions without the registry serve an + * empty roster and refuse observation with a stable error. + */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-activity' +import { ActivityFeed } from './feed.ts' +import { observeActivity } from './observe.ts' +import type { ActivityControlFrame, ActivityObserveFrame, ActivityObserveRequest } from './types.ts' + +export type * from './types.ts' + +/** Default coalescing window between observation reads, in milliseconds. */ +const DEFAULT_FLUSH_MS = 100 + +/** Default soft byte budget per observation output frame. */ +const DEFAULT_MAX_FRAME_BYTES = 64 * 1024 + +/** Configuration for the Activity Remote owner. */ +export interface Config { + /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ + flushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + maxFrameBytes?: number +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host Activity read API and Remote namespace owner. */ + activityController: ActivityController + } +} + +/** Host service backing the generated `ctx.remote.activity` namespace. */ +export class ActivityController extends TypertRemoteService { + static inject = ['typert'] + + static Config: z = z.object({ + flushMs: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_FLUSH_MS), + maxFrameBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_MAX_FRAME_BYTES), + }) + + private readonly feed: ActivityFeed + private readonly flushMs: number + private readonly maxFrameBytes: number + + /** + * @param ctx - Host context; the activity registry stays optional. + * @param config - validated coalescing and framing bounds. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'activityController', { namespace: 'activity' }) + this.flushMs = (config as Required).flushMs + this.maxFrameBytes = (config as Required).maxFrameBytes + this.feed = new ActivityFeed(ctx) + } + + /** + * Stream a complete roster baseline followed by whole-bucket replacements. + * @param signal - generation cancellation. + * @returns baseline followed by per-owner roster replacement frames. + */ + @Remote({ mode: 'stream' }) + control(signal: AbortSignal): AsyncIterable { + return this.feed.follow(signal) + } + + /** + * Stream one activity's retained output from an absolute byte offset, then + * its terminal status once settled and drained. Non-consuming: the + * model-facing cursors never observe these reads. + * @param request - target activity and optional resume offset. + * @param signal - generation cancellation. + * @returns anchor, coalesced output frames, and the terminal status. + */ + @Remote({ mode: 'stream' }) + observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable { + const registry = this.ctx.get('activities') + if (registry === undefined) { + throw new Error('activity observation unavailable: no activity registry is loaded (load @deepseek-ai/dsh-activity-local)') + } + return observeActivity(registry, request, { + flushMs: this.flushMs, + maxFrameBytes: this.maxFrameBytes, + ownerOf: id => this.feed.ownerOf(id), + }, signal) + } +} + +export default ActivityController diff --git a/packages/api/activity-controller/src/invariant.ts b/packages/api/activity-controller/src/invariant.ts new file mode 100644 index 0000000000..f85e7460b8 --- /dev/null +++ b/packages/api/activity-controller/src/invariant.ts @@ -0,0 +1,23 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-activity-controller/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-api-activity-controller' + +/** Cordis companion plugin name. */ +export const name = 'api-activity-controller-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: `@deepseek-ai/dsh-activity/invariant` owns snapshot relations; every roster + * generation is a full projection and observation reads are validated by the registry itself. + */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/api/activity-controller/src/observe.ts b/packages/api/activity-controller/src/observe.ts new file mode 100644 index 0000000000..f793b11496 --- /dev/null +++ b/packages/api/activity-controller/src/observe.ts @@ -0,0 +1,178 @@ +/** Per-activity observation generations: anchor, coalesced output, terminal status. */ + +import type { ActivityRegistry, ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import { ActivityId } from '@deepseek-ai/dsh-activity/brand' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ActivityObserveFrame, ActivityObserveRequest, ActivityWireChunk } from './types.ts' + +/** Cadence and framing bounds for one observation generation. */ +export interface ObserveOptions { + /** Coalescing window after a wake before reading, in milliseconds. */ + readonly flushMs: number + /** + * Soft byte budget per `output` frame. Accumulated chunks flush once the + * budget is met; one chunk larger than the budget ships whole (the + * registry's retention cap is the hard bound on any single chunk). + */ + readonly maxFrameBytes: number + /** Resolve the exact owner for fenced reads (the roster mirror's `ownerOf`). */ + readonly ownerOf: (id: string) => Agent | undefined +} + +function isTerminal(status: ActivitySnapshot['status']): boolean { + return status !== 'running' +} + +/** Wake-flag waiter: a wake between waits is never lost. */ +class OutputWaiter { + private dirty = false + private resolve: (() => void) | undefined + + wake(): void { + this.dirty = true + this.resolve?.() + } + + /** + * Resolve on the next wake, immediately when one already arrived, or on abort. + * @param signal - generation cancellation. + */ + wait(signal: AbortSignal): Promise { + if (this.dirty || signal.aborted) { + this.dirty = false + return Promise.resolve() + } + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one wait owns the sole installed resolver. */ + if (this.resolve === finish) this.resolve = undefined + this.dirty = false + resolve() + } + this.resolve = finish + signal.addEventListener('abort', finish, { once: true }) + }) + } +} + +function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} + +/** + * Stream one activity's retained output from an absolute offset: one `opened` + * anchor, coalesced `output` frames as the stream advances, then one terminal + * `status` after the settled activity is drained, after which the generation + * closes normally. Reads are non-consuming; reconnecting callers resume by + * passing the last frame's `next` as `from`. + * @param registry - the live activity registry. + * @param request - target activity and optional resume offset. + * @param options - cadence, framing bounds, and owner resolution. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the observation frame sequence for one generation. + */ +export async function* observeActivity( + registry: ActivityRegistry, + request: ActivityObserveRequest, + options: ObserveOptions, + signal: AbortSignal, +): AsyncIterable { + if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { + throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) + } + signal.throwIfAborted() + const id = ActivityId(String(request.activityId)) + const waiter = new OutputWaiter() + // Subscribe before the first read so an append between the anchor read and + // the wait cannot be missed. + const unsubscribe = registry.onOutput((changed) => { + if (changed === id) waiter.wake() + }) + try { + const caller = options.ownerOf(String(id)) + let snapshot = registry.get(id, caller) + let cursor = request.from ?? snapshot.outputEarliest + yield { + type: 'opened', + activityId: id, + from: cursor, + earliest: snapshot.outputEarliest, + total: snapshot.outputTotal, + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + } + while (!signal.aborted) { + const read = registry.read(id, cursor, caller) + if (read.chunks.length > 0) { + yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes) + } + cursor = read.next + snapshot = registry.get(id, caller) + if (isTerminal(snapshot.status) && cursor >= snapshot.outputTotal) { + yield { + type: 'status', + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + // Settlement always stamps finishedAt; the guard only discharges the optional field type. + /* v8 ignore next */ + ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, + } + return + } + await waiter.wait(signal) + // Let a burst accumulate so producer chatter becomes bounded frames. + await sleep(options.flushMs, signal) + } + } finally { + unsubscribe() + } +} + +/** Split one read into frames along the soft per-frame byte budget. */ +function* outputFrames( + chunks: readonly ActivityWireChunk[], + next: number, + lossy: boolean, + maxFrameBytes: number, +): Iterable { + let batch: ActivityWireChunk[] = [] + let batchBytes = 0 + let flaggedLossy = lossy + for (const chunk of chunks) { + batch.push(chunk) + batchBytes += Buffer.byteLength(chunk.text, 'utf8') + if (batchBytes >= maxFrameBytes) { + const last = batch[batch.length - 1] + /* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */ + const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, 'utf8') + /* v8 ignore stop */ + yield { + type: 'output', + chunks: batch, + next: end, + ...flaggedLossy ? { lossy: true as const } : {}, + } + flaggedLossy = false + batch = [] + batchBytes = 0 + } + } + if (batch.length > 0 || flaggedLossy) { + yield { + type: 'output', + chunks: batch, + next, + ...flaggedLossy ? { lossy: true as const } : {}, + } + } +} diff --git a/packages/api/activity-controller/src/types.ts b/packages/api/activity-controller/src/types.ts new file mode 100644 index 0000000000..2116d4477b --- /dev/null +++ b/packages/api/activity-controller/src/types.ts @@ -0,0 +1,104 @@ +/** Browser-safe roster and observation vocabulary for the Activity Remote. */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { CallId } from '@deepseek-ai/dsh-llm/brand' + +export type { ActivityId } from '@deepseek-ai/dsh-activity/brand' + +/** Lifecycle state carried on the wire; mirrors the registry's status union. */ +export type ActivityRowStatus = 'running' | 'completed' | 'failed' | 'killed' + +/** Links one roster row to the tool call and background job for the same work. */ +export interface ActivityRowCorrelation { + /** Tool call that started the work, when a tool produced it. */ + readonly callId?: CallId + /** Background job carrying the model-facing control surface, when one exists. */ + readonly jobId?: JobId +} + +/** + * Browser-safe activity row. `kind` is `string` on the wire because the kind + * map is merge-extensible by producer plugins; presentation falls through a + * documented default for an unrecognized kind. + */ +export interface ActivityRow { + readonly id: ActivityId + readonly kind: string + readonly label: string + /** Owning session for grouping; absent for an unowned activity, visible to every session. */ + readonly sessionId?: SessionId + readonly correlation?: ActivityRowCorrelation + readonly status: ActivityRowStatus + readonly detail?: string + readonly startedAt: number + readonly finishedAt?: number + /** Total UTF-8 bytes the activity has produced so far. */ + readonly outputTotal: number +} + +/** + * Roster stream frames. Each generation starts with exactly one complete + * baseline; later frames replace one owner bucket wholesale (absent + * `sessionId` = the unowned bucket), so start, settlement, removal, and a + * second browser tab all converge through one authoritative value. + */ +export type ActivityControlFrame = + | { readonly type: 'baseline'; readonly activities: readonly ActivityRow[] } + | { readonly type: 'rows'; readonly sessionId?: SessionId; readonly activities: readonly ActivityRow[] } + +/** Opens one observation stream over a single activity's retained output. */ +export interface ActivityObserveRequest { + readonly activityId: ActivityId + /** + * Absolute byte offset to resume from — a previous frame's `next`. Omitted + * starts at the oldest retained byte. + */ + readonly from?: number +} + +/** One retained output chunk on the wire. `channel` widens to string like `kind`. */ +export interface ActivityWireChunk { + /** Absolute offset of the chunk's first byte. */ + readonly at: number + readonly text: string + readonly channel?: string + /** Bytes immediately before this chunk were lost at the producer or to retention. */ + readonly gapBefore?: true +} + +/** + * Observation stream frames: one `opened` anchor, then coalesced `output` + * batches, then — once the activity has settled and its retained output is + * drained — one terminal `status`, after which the stream closes normally. + * Status rides the same stream as output so settlement can never race a + * still-open output channel. + */ +export type ActivityObserveFrame = + | { + readonly type: 'opened' + readonly activityId: ActivityId + /** Offset the first `output` frame continues from. */ + readonly from: number + /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ + readonly earliest: number + /** Total bytes produced at open time. */ + readonly total: number + readonly status: ActivityRowStatus + readonly detail?: string + } + | { + readonly type: 'output' + readonly chunks: readonly ActivityWireChunk[] + /** Offset to resume from after this frame. */ + readonly next: number + /** Bytes between the requested offset and `chunks` were already evicted. */ + readonly lossy?: true + } + | { + readonly type: 'status' + readonly status: ActivityRowStatus + readonly detail?: string + readonly finishedAt?: number + } diff --git a/packages/api/activity-controller/tests/activity-controller.host.spec.ts b/packages/api/activity-controller/tests/activity-controller.host.spec.ts new file mode 100644 index 0000000000..757a0af115 --- /dev/null +++ b/packages/api/activity-controller/tests/activity-controller.host.spec.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import { ActivityId } from '@deepseek-ai/dsh-activity' +import { ActivityFeed } from '../src/feed.ts' +import { observeActivity } from '../src/observe.ts' +import type { ObserveOptions } from '../src/observe.ts' +import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts' + +async function harness(withRegistry = true) { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + if (withRegistry) await ctx.plugin(LocalActivityRegistry, { retainBytes: 64, settledRetainBytes: 64 }) + return ctx +} + +function registerAgent(ctx: Context, rawId: string): Agent { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(rawId) + const session = Session.create(id) + const agent = { + id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scopeFiber.ctx, + } as unknown as Agent + ctx.agents.register(agent) + return agent +} + +/** Read frames until `count` arrive or the generator finishes, then abort. */ +async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: Frame[] = [] + const iterator = iterable[Symbol.asyncIterator]() + try { + while (frames.length < count) { + const step = await iterator.next() + if (step.done) return frames + frames.push(step.value) + } + } finally { + abort.abort() + await iterator.next().catch(() => undefined) + } + return frames +} + +const OBSERVE: Omit = { flushMs: 5, maxFrameBytes: 1024 } + +describe('ActivityFeed roster', () => { + it('serves an empty baseline without the registry', async () => { + const ctx = await harness(false) + const feed = new ActivityFeed(ctx) + expect(feed.baseline()).toEqual([]) + }) + + it('projects owned and unowned rows into the baseline with correlation and no live objects', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + ctx.activities.open({ kind: 'bash', label: 'unowned build' }) + ctx.activities.open({ + kind: 'bash', + label: 'owned build', + owner, + correlation: { jobId: 'bash-7' as never }, + }) + const feed = new ActivityFeed(ctx) + const rows = feed.baseline() + expect(rows).toHaveLength(2) + const owned = rows.find(row => row.sessionId !== undefined) + expect(owned?.sessionId).toBe('alice') + expect(owned?.correlation).toEqual({ jobId: 'bash-7' }) + expect(owned?.status).toBe('running') + for (const row of rows) { + expect(Object.keys(row)).not.toContain('owner') + expect(row.outputTotal).toBe(0) + } + }) + + it('pushes whole-bucket frames for open, detail, settlement, and removal', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + const feed = new ActivityFeed(ctx) + const abort = new AbortController() + const frames = collect(feed.follow(abort.signal), 4, abort) + await new Promise(resolve => setTimeout(resolve, 0)) + const handle = ctx.activities.open({ kind: 'bash', label: 'work', owner }) + handle.updateDetail('halfway') + handle.end({ status: 'completed', detail: 'exit code: 0' }) + const seen = await frames + expect(seen[0]).toEqual({ type: 'baseline', activities: [] }) + const rows = seen.slice(1) as Extract[] + expect(rows.every(frame => frame.sessionId === 'alice')).toBe(true) + expect(rows[0]?.activities[0]?.status).toBe('running') + expect(rows[1]?.activities[0]?.detail).toBe('halfway') + expect(rows[2]?.activities[0]?.status).toBe('completed') + expect(feed.ownerOf(String(rows[0]!.activities[0]!.id))).toBe(owner) + }) + + it('projects callId-only correlation and serves rosters without an agent registry', async () => { + const bare = new Context() + await bare.plugin(LocalActivityRegistry) + bare.activities.open({ kind: 'bash', label: 'lone', correlation: { callId: 'call-1' as never } }) + const feed = new ActivityFeed(bare) + expect(feed.baseline()[0]?.correlation).toEqual({ callId: 'call-1' }) + }) + + it('pushes an empty bucket frame when owner disposal removes the last row', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + const feed = new ActivityFeed(ctx) + ctx.activities.open({ kind: 'bash', label: 'doomed', owner }) + const abort = new AbortController() + const frames = collect(feed.follow(abort.signal), 3, abort) + await new Promise(resolve => setTimeout(resolve, 0)) + await (owner.ctx as unknown as { fiber: { dispose(): Promise } }).fiber?.dispose?.() + // Owner-disposal removal reaches the feed as an emptied bucket. + await new Promise(resolve => setTimeout(resolve, 10)) + abort.abort() + const seen = await frames + const last = seen.at(-1) as Extract | undefined + if (last !== undefined && last.type === 'rows') expect(Array.isArray(last.activities)).toBe(true) + }) + + it('resolves undefined for unowned and unknown activities', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'shared' }) + expect(feed.ownerOf(String(handle.id))).toBeUndefined() + expect(feed.ownerOf('bash-999')).toBeUndefined() + }) +}) + +describe('observeActivity', () => { + async function observed(ctx: Context, feed: ActivityFeed, request: { activityId: ActivityId; from?: number }) { + const abort = new AbortController() + const frames: ActivityObserveFrame[] = [] + const stream = observeActivity( + ctx.activities, + request, + { ...OBSERVE, ownerOf: id => feed.ownerOf(id) }, + abort.signal, + ) + const done = (async () => { + for await (const frame of stream) frames.push(frame) + })() + return { abort, frames, done } + } + + it('anchors, streams coalesced output, and closes with the terminal status', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('early ') + const { frames, done } = await observed(ctx, feed, { activityId: handle.id }) + await new Promise(resolve => setTimeout(resolve, 20)) + handle.append('live', { channel: 'stderr' }) + await new Promise(resolve => setTimeout(resolve, 20)) + handle.end({ status: 'completed', detail: 'exit code: 0' }) + await done + + expect(frames[0]).toMatchObject({ type: 'opened', from: 0, earliest: 0, total: 6, status: 'running' }) + const outputs = frames.filter(frame => frame.type === 'output') + const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') + expect(text).toBe('early live') + expect(outputs.at(-1)?.next).toBe(10) + expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr') + expect(frames.at(-1)).toMatchObject({ type: 'status', status: 'completed', detail: 'exit code: 0' }) + }) + + it('resumes from an explicit offset and flags an evicted resume lossy', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'spam' }) + handle.append('a'.repeat(60)) + handle.append('b'.repeat(60)) + handle.end({ status: 'completed' }) + + const fresh = await observed(ctx, feed, { activityId: handle.id, from: 0 }) + await fresh.done + const output = fresh.frames.find(frame => frame.type === 'output') + expect(output?.lossy).toBe(true) + expect(output?.chunks.map(chunk => chunk.text).join('')).toBe('b'.repeat(60)) + + const resumed = await observed(ctx, feed, { activityId: handle.id, from: 120 }) + await resumed.done + expect(resumed.frames.some(frame => frame.type === 'output')).toBe(false) + expect(resumed.frames.at(-1)?.type).toBe('status') + }) + + it('splits oversized reads along the frame budget', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const registry = ctx.activities + const handle = registry.open({ kind: 'bash', label: 'wide' }) + const abort = new AbortController() + handle.append('x'.repeat(30)) + handle.append('y'.repeat(30)) + handle.end({ status: 'completed' }) + const frames = await collect( + observeActivity(registry, { activityId: handle.id }, { + flushMs: 5, + maxFrameBytes: 30, + ownerOf: id => feed.ownerOf(id), + }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs).toHaveLength(2) + expect(outputs[0]?.next).toBe(30) + expect(outputs[1]?.next).toBe(60) + }) + + it('carries the lossy flag on the first frame of a split evicted read', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const registry = ctx.activities + const handle = registry.open({ kind: 'bash', label: 'evicted split' }) + handle.append('a'.repeat(40)) + handle.append('b'.repeat(40)) + handle.append('c'.repeat(40)) + handle.end({ status: 'completed' }) + const abort = new AbortController() + const frames = await collect( + observeActivity(registry, { activityId: handle.id, from: 0 }, { + flushMs: 5, + maxFrameBytes: 40, + ownerOf: id => feed.ownerOf(id), + }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs[0]?.lossy).toBe(true) + expect(outputs[1]?.lossy).toBeUndefined() + }) + + it('reads owned activities through the roster owner and rejects bad offsets and unknown ids', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'secret', owner }) + handle.append('classified') + handle.end({ status: 'completed' }) + const { frames, done } = await observed(ctx, feed, { activityId: handle.id }) + await done + expect(frames.some(frame => frame.type === 'output' + && frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true) + + const abort = new AbortController() + await expect(collect( + observeActivity(ctx.activities, { activityId: handle.id, from: -1 }, { + ...OBSERVE, + ownerOf: id => feed.ownerOf(id), + }, abort.signal), + 1, + abort, + )).rejects.toThrow(/invalid observe offset/) + + const abortUnknown = new AbortController() + await expect(collect( + observeActivity(ctx.activities, { activityId: ActivityId('bash-999') }, { + ...OBSERVE, + ownerOf: id => feed.ownerOf(id), + }, abortUnknown.signal), + 1, + abortUnknown, + )).rejects.toThrow(/unknown activity/) + }) + + it('ignores output signals for other activities while waiting', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const watched = ctx.activities.open({ kind: 'bash', label: 'watched' }) + const noisy = ctx.activities.open({ kind: 'bash', label: 'noisy' }) + const { abort, frames, done } = await observed(ctx, feed, { activityId: watched.id }) + await new Promise(resolve => setTimeout(resolve, 15)) + noisy.append('unrelated') + await new Promise(resolve => setTimeout(resolve, 15)) + watched.end({ status: 'completed' }) + await done + abort.abort() + expect(frames.filter(frame => frame.type === 'output')).toEqual([]) + }) + + it('stops cleanly on abort while waiting for output', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'idle' }) + const { abort, frames, done } = await observed(ctx, feed, { activityId: handle.id }) + await new Promise(resolve => setTimeout(resolve, 20)) + abort.abort() + await done + expect(frames[0]?.type).toBe('opened') + expect(frames.at(-1)?.type).not.toBe('status') + }) +}) diff --git a/packages/api/activity-controller/tests/client-apply.client.spec.ts b/packages/api/activity-controller/tests/client-apply.client.spec.ts new file mode 100644 index 0000000000..231703ba13 --- /dev/null +++ b/packages/api/activity-controller/tests/client-apply.client.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' +import { apply, createActivityRosterStream } from '../src/client/index.ts' +import { ClientActivityModel } from '../src/client/model.ts' +import type { ActivityControlFrame, ActivityId, ActivityRow } from '../src/types.ts' + +function row(): ActivityRow { + return { + id: 'bash-1' as ActivityId, + kind: 'bash', + label: 'build', + status: 'running', + startedAt: 1, + outputTotal: 0, + } +} + +/** One scripted roster stream: frames pushed by the test, never reopened. */ +class FakeStream { + disposed = false + private readonly frames: ActivityControlFrame[] = [] + private waiter: (() => void) | undefined + private failure: Error | undefined + + push(frame: ActivityControlFrame): void { + this.frames.push(frame) + this.waiter?.() + } + + poison(error: Error): void { + this.failure = error + this.waiter?.() + } + + dispose(): Promise { + this.disposed = true + this.waiter?.() + return Promise.resolve() + } + + async *[Symbol.asyncIterator]() { + while (!this.disposed) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame === undefined) { + await new Promise((resolve) => { this.waiter = resolve }) + continue + } + yield { generation: 1, value: frame, signal: new AbortController().signal, accept: () => {} } + } + } +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +describe('activity-controller client plugin', () => { + it('installs ctx.activityFeed and folds roster frames from the control stream', async () => { + const ctx = new Context() + const streams: { options: { open: (signal: AbortSignal) => unknown; ended: (accepted: boolean) => Error }; stream: FakeStream }[] = [] + const controlCalls: AbortSignal[] = [] + ctx.provide('remote', { + $stream: (options: never) => { + const stream = new FakeStream() + streams.push({ options, stream }) + return stream + }, + activity: { + control: (signal: AbortSignal) => { + controlCalls.push(signal) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, + observe: () => { throw new Error('not exercised') }, + }, + } as never) + const fiber = ctx.plugin({ inject: ['remote'], apply }) + await fiber + + streams[0]!.stream.push({ type: 'baseline', activities: [row()] }) + streams[0]!.stream.push({ type: 'rows', activities: [] }) + await tick() + const snapshot = ctx.activityFeed.state.getSnapshot() + expect(snapshot.phase).toBe('ready') + expect(snapshot.rowsBySession['']).toBeUndefined() + + // The generation opener and end classification belong to the domain. + streams[0]!.options.open(new AbortController().signal) + expect(controlCalls).toHaveLength(1) + expect(streams[0]!.options.ended(true)).toBeInstanceOf(RemoteStreamCarrierError) + expect(streams[0]!.options.ended(false)).toBeInstanceOf(Error) + + // A terminal roster failure keeps the last snapshot in place. + streams[0]!.stream.poison(new Error('roster stream broke')) + await tick() + expect(ctx.activityFeed.state.getSnapshot().phase).toBe('ready') + + await fiber.dispose() + expect(streams[0]!.stream.disposed).toBe(true) + }) + + it('keeps the last roster on a terminal stream failure', async () => { + const model = new ClientActivityModel() + const remote = { + $stream: () => new FakeStream(), + activity: { control: () => { throw new Error('unused') }, observe: () => { throw new Error('unused') } }, + } + const stream = createActivityRosterStream(remote as never, model) + model.replaceBaseline([row()]) + stream.start() + await stream.dispose() + expect(model.getSnapshot().rowsBySession['']?.[0]?.label).toBe('build') + }) +}) diff --git a/packages/api/activity-controller/tests/client-model.client.spec.ts b/packages/api/activity-controller/tests/client-model.client.spec.ts new file mode 100644 index 0000000000..59ec9067c5 --- /dev/null +++ b/packages/api/activity-controller/tests/client-model.client.spec.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { ClientActivityModel } from '../src/client/model.ts' +import type { ActivityId, ActivityRow } from '../src/types.ts' + +function row(over: Partial = {}): ActivityRow { + return { + id: 'bash-1' as ActivityId, + kind: 'bash', + label: 'build', + sessionId: 'alice' as ActivityRow['sessionId'], + status: 'running', + startedAt: 1, + outputTotal: 0, + ...over, + } as ActivityRow +} + +/** A row with no owner session, as the unowned bucket carries them. */ +function unownedRow(over: Partial = {}): ActivityRow { + const { sessionId: _drop, ...rest } = row(over) + return rest +} + +const ID = 'bash-1' as ActivityId + +function opened(model: ClientActivityModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { + model.observeOpened(ID, { + type: 'opened', + activityId: ID, + from: 0, + earliest: 0, + total: 0, + status: 'running', + ...over, + }) +} + +describe('ClientActivityModel roster', () => { + it('folds baselines into per-session buckets and reports readiness', () => { + const model = new ClientActivityModel() + expect(model.getSnapshot().phase).toBe('pending') + model.replaceBaseline([row(), unownedRow({ id: 'workflow-1' as ActivityId })]) + const snapshot = model.getSnapshot() + expect(snapshot.phase).toBe('ready') + expect(snapshot.rowsBySession['alice']).toHaveLength(1) + expect(snapshot.rowsBySession['']).toHaveLength(1) + }) + + it('replaces buckets wholesale and drops emptied keys', () => { + const model = new ClientActivityModel() + model.replaceBaseline([row()]) + model.replaceBucket('alice', [row({ status: 'completed', finishedAt: 2 })]) + expect(model.getSnapshot().rowsBySession['alice']?.[0]?.status).toBe('completed') + model.replaceBucket('alice', []) + expect('alice' in model.getSnapshot().rowsBySession).toBe(false) + model.replaceBucket(undefined, [unownedRow()]) + expect(model.getSnapshot().rowsBySession['']).toHaveLength(1) + }) + + it('keeps snapshot identity stable between changes and notifies subscribers', () => { + const model = new ClientActivityModel() + let notified = 0 + const unsubscribe = model.subscribe(() => { notified += 1 }) + const before = model.getSnapshot() + expect(model.getSnapshot()).toBe(before) + model.replaceBaseline([row()]) + expect(notified).toBe(1) + expect(model.getSnapshot()).not.toBe(before) + unsubscribe() + model.replaceBucket('alice', []) + expect(notified).toBe(1) + }) +}) + +describe('ClientActivityModel observation', () => { + it('accumulates output, advances the resume cursor, and settles on status', () => { + const model = new ClientActivityModel() + opened(model) + expect(model.cursorOf(ID)).toBe(0) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 }) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 }) + expect(model.cursorOf(ID)).toBe(3) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text).toBe('abc') + expect(view?.streaming).toBe(true) + model.observeStatus(ID, { type: 'status', status: 'completed', detail: 'exit code: 0' }) + const settled = model.getSnapshot().observed[String(ID)] + expect(settled?.streaming).toBe(false) + expect(settled?.status).toBe('completed') + expect(settled?.detail).toBe('exit code: 0') + }) + + it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => { + const model = new ClientActivityModel() + opened(model, { from: 4, earliest: 8, total: 10 }) + expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const clean = new ClientActivityModel() + opened(clean) + clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 }) + expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const lossy = new ClientActivityModel() + opened(lossy) + lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true }) + expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + + it('bounds the render tail without splitting a surrogate pair', () => { + const model = new ClientActivityModel() + opened(model) + const emoji = '😀'.repeat((64 * 1024) + 8) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.gapBefore).toBe(true) + expect(view!.text.length).toBeLessThanOrEqual(128 * 1024) + // The bound landed between pairs: the surviving text still round-trips. + expect(/^(?:😀)+$/u.test(view!.text)).toBe(true) + }) + + it('carries the anchor detail and trims a plain-ASCII tail without a boundary shift', () => { + const model = new ClientActivityModel() + model.observeOpened(ID, { + type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running', detail: 'exit soon', + }) + expect(model.getSnapshot().observed[String(ID)]?.detail).toBe('exit soon') + const long = 'x'.repeat((128 * 1024) + 5) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text.length).toBe(128 * 1024) + expect(view?.gapBefore).toBe(true) + }) + + it('advances the cut past a low surrogate landing exactly on the bound', () => { + const model = new ClientActivityModel() + opened(model) + // 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index. + const text = 'z😀' + 'a'.repeat((128 * 1024) - 1) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text.length).toBe((128 * 1024) - 1) + expect(view?.text.startsWith('a')).toBe(true) + }) + + it('preserves accumulated text across a reconnect anchor and clears on stop', () => { + const model = new ClientActivityModel() + opened(model) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 }) + opened(model, { from: 4, earliest: 0, total: 4 }) + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('kept') + model.observeStopped(ID) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + expect(model.cursorOf(ID)).toBeUndefined() + // A second stop is inert. + model.observeStopped(ID) + }) + + it('records a terminal stream failure on the live view', () => { + const model = new ClientActivityModel() + opened(model) + model.observeFailed(ID, new Error('carrier gone')) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.streaming).toBe(false) + expect(view?.error).toContain('carrier gone') + // A failure for an untracked id is inert. + model.observeFailed('bash-9' as ActivityId, new Error('ignored')) + }) +}) diff --git a/packages/api/activity-controller/tests/client-service.client.spec.ts b/packages/api/activity-controller/tests/client-service.client.spec.ts new file mode 100644 index 0000000000..c27e66cdd0 --- /dev/null +++ b/packages/api/activity-controller/tests/client-service.client.spec.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { ClientActivityModel } from '../src/client/model.ts' +import { ClientActivityFeed } from '../src/client/service.ts' +import type { ActivityId, ActivityObserveFrame } from '../src/types.ts' + +const ID = 'bash-1' as ActivityId + +/** One scripted logical stream: frames are pushed by the test, never reopened. */ +class FakeStream { + disposed = false + /** Model a carrier whose disposal surfaces as an iterator throw. */ + throwOnDispose = false + private readonly frames: ActivityObserveFrame[] = [] + private waiter: (() => void) | undefined + private failure: Error | undefined + readonly accepts: number[] = [] + + push(frame: ActivityObserveFrame): void { + this.frames.push(frame) + this.waiter?.() + } + + poison(error: Error): void { + this.failure = error + this.waiter?.() + } + + dispose(): Promise { + if (this.throwOnDispose && this.failure === undefined) { + this.failure = new Error('carrier tore down') + } else { + this.disposed = true + } + this.waiter?.() + return Promise.resolve() + } + + async *[Symbol.asyncIterator]() { + let generation = 1 + while (!this.disposed) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame === undefined) { + await new Promise((resolve) => { this.waiter = resolve }) + continue + } + yield { + generation, + value: frame, + signal: new AbortController().signal, + accept: () => { this.accepts.push(generation) }, + } + generation = 1 + } + } +} + +function bench() { + const ctx = new Context() + const model = new ClientActivityModel() + const streams: { options: { open: (signal: AbortSignal) => unknown }; stream: FakeStream }[] = [] + const observeCalls: unknown[] = [] + const remote = { + $stream: (options: { open: (signal: AbortSignal) => unknown }) => { + const stream = new FakeStream() + streams.push({ options, stream }) + return stream + }, + activity: { + observe: (request: unknown) => { + observeCalls.push(request) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, + control: () => { throw new Error('not exercised') }, + }, + } + const feed = new ClientActivityFeed(ctx, remote as never, model) + return { ctx, model, feed, streams, observeCalls } +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +describe('ClientActivityFeed observation streams', () => { + it('feeds anchor, output, and terminal frames into the model, then closes', async () => { + const { model, feed, streams } = bench() + feed.observe(ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi') + expect(stream.accepts).toHaveLength(1) + + stream.push({ type: 'status', status: 'completed' }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) + expect(stream.disposed).toBe(true) + }) + + it('shares one stream across observers and stops after the last release', async () => { + const { model, feed, streams } = bench() + const first = feed.observe(ID) + const second = feed.observe(ID) + expect(streams).toHaveLength(1) + streams[0]!.stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + + first() + first() + expect(streams[0]!.stream.disposed).toBe(false) + second() + await tick() + expect(streams[0]!.stream.disposed).toBe(true) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + }) + + it('opens a fresh stream for a re-observed activity and resumes from the model cursor', async () => { + const { feed, streams, observeCalls, model } = bench() + const stop = feed.observe(ID) + streams[0]!.stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 }) + await tick() + // The generation opener reads the live cursor at open time. + streams[0]!.options.open(new AbortController().signal) + expect(observeCalls.at(-1)).toEqual({ activityId: ID, from: 3 }) + + stop() + await tick() + feed.observe(ID) + expect(streams).toHaveLength(2) + streams[1]!.options.open(new AbortController().signal) + // Stopped observation dropped the cursor: the fresh stream starts unanchored. + expect(observeCalls.at(-1)).toEqual({ activityId: ID }) + expect(model.cursorOf(ID)).toBeUndefined() + }) + + it('classifies a premature end as retryable only after the anchor', () => { + const { feed, streams } = bench() + feed.observe(ID) + const options = streams[0]!.options as unknown as { ended: (accepted: boolean) => Error } + expect(options.ended(true).name).toBe('RemoteStreamCarrierError') + expect(options.ended(false).name).toBe('Error') + }) + + it('records a consumer failure on the live view', async () => { + const { model, feed, streams } = bench() + feed.observe(ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + stream.poison(new Error('frame decode broke')) + await tick() + const view = model.getSnapshot().observed[String(ID)] + expect(view?.error).toContain('frame decode broke') + expect(view?.streaming).toBe(false) + }) + + it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => { + const { ctx, model, feed, streams } = bench() + const stop = feed.observe(ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + // Poison wakes the consumer, but the synchronous stop lands first, so the + // failure arrives on an already-stopped entry and stays silent. + stream.poison(new Error('after stop')) + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + + const second = feed.observe(ID) + await ctx.fiber.dispose() + // The disposal path already removed every entry; a live releaser whose + // entry is gone is inert. + second() + }) + + it('stays silent when the carrier teardown itself throws after a stop', async () => { + const { model, feed, streams } = bench() + const stop = feed.observe(ID) + const { stream } = streams[0]! + stream.throwOnDispose = true + stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + // The releaser marks the entry stopped, then its dispose surfaces as an + // iterator throw — which the consumer swallows for a stopped entry. + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined() + }) + + it('records a terminal stream failure and service disposal closes live streams', async () => { + const { ctx, model, feed, streams } = bench() + feed.observe(ID) + const failing = streams[0]!.stream + failing.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + const iterator = failing[Symbol.asyncIterator]() + void iterator + // Simulate a terminal consumer failure by disposing the underlying stream: + // the consumer loop ends without a status frame and cleans up. + const spy = vi.spyOn(model, 'observeFailed') + await failing.dispose() + await tick() + + const other = 'bash-2' as ActivityId + feed.observe(other) + await ctx.fiber.dispose() + expect(streams.every(entry => entry.stream.disposed)).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api/activity-controller/tests/controller.host.spec.ts b/packages/api/activity-controller/tests/controller.host.spec.ts new file mode 100644 index 0000000000..4c3262f093 --- /dev/null +++ b/packages/api/activity-controller/tests/controller.host.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import { ActivityController } from '../src/index.ts' +import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts' + +async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: Frame[] = [] + const iterator = iterable[Symbol.asyncIterator]() + try { + while (frames.length < count) { + const step = await iterator.next() + if (step.done) return frames + frames.push(step.value) + } + } finally { + abort.abort() + await iterator.next().catch(() => undefined) + } + return frames +} + +describe('ActivityController', () => { + it('closes live roster followers when the owning fiber unwinds', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry) + const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) + const abort = new AbortController() + const iterator = controller.control(abort.signal)[Symbol.asyncIterator]() + expect((await iterator.next()).value).toMatchObject({ type: 'baseline' }) + const pending = iterator.next() + await ctx.fiber.dispose() + // The feed effect closed the follower, ending the generation cleanly. + expect((await pending).done).toBe(true) + }) + + it('coalesces bursts that land inside the flush window and carries the anchor detail', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry) + const controller = new ActivityController(ctx, { flushMs: 30, maxFrameBytes: 1024 }) + const handle = ctx.activities.open({ kind: 'bash', label: 'burst' }) + handle.updateDetail('warming up') + const abort = new AbortController() + const frames: ActivityObserveFrame[] = [] + const done = (async () => { + for await (const frame of controller.observe({ activityId: handle.id }, abort.signal)) frames.push(frame) + })() + await new Promise(resolve => setTimeout(resolve, 10)) + handle.append('a') + await new Promise(resolve => setTimeout(resolve, 5)) + // Lands inside the flush sleep, so the waiter is already dirty at the next wait. + handle.append('b') + await new Promise(resolve => setTimeout(resolve, 80)) + handle.end({ status: 'completed' }) + await done + expect(frames[0]).toMatchObject({ type: 'opened', detail: 'warming up' }) + const text = frames.filter(frame => frame.type === 'output') + .flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') + expect(text).toBe('ab') + }) + + it('serves an empty baseline and refuses observation without the registry', async () => { + const ctx = new Context() + const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) + const abort = new AbortController() + const frames = await collect(controller.control(abort.signal), 1, abort) + expect(frames).toEqual([{ type: 'baseline', activities: [] }]) + expect(() => controller.observe({ activityId: 'bash-1' as never }, new AbortController().signal)) + .toThrow(/activity observation unavailable/) + }) + + it('streams the roster and one activity end to end over a live registry', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry) + const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) + + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('hi') + handle.end({ status: 'completed', detail: 'exit code: 0' }) + + const rosterAbort = new AbortController() + const roster = await collect(controller.control(rosterAbort.signal), 1, rosterAbort) + expect(roster[0]).toMatchObject({ type: 'baseline' }) + expect((roster[0] as Extract).activities[0]?.label).toBe('echo') + + const observeAbort = new AbortController() + const observed = await collect( + controller.observe({ activityId: handle.id }, observeAbort.signal), + 3, + observeAbort, + ) + expect(observed.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + }) +}) diff --git a/packages/api/activity-controller/tsconfig.client.json b/packages/api/activity-controller/tsconfig.client.json new file mode 100644 index 0000000000..d2934629b4 --- /dev/null +++ b/packages/api/activity-controller/tsconfig.client.json @@ -0,0 +1,46 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/model.ts", + "src/client/service.ts", + "src/types.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../gateway/tsconfig.client.json" + }, + { + "path": "../../client/connection/tsconfig.client.json" + }, + { + "path": "../../client/store" + }, + { + "path": "../../activity/activity" + }, + { + "path": "../../core/session" + }, + { + "path": "../../jobs/jobs" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../typert/protocol" + } + ] +} diff --git a/packages/api/activity-controller/tsconfig.host.json b/packages/api/activity-controller/tsconfig.host.json new file mode 100644 index 0000000000..4193d47109 --- /dev/null +++ b/packages/api/activity-controller/tsconfig.host.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/invariant.ts", + "src/types.ts", + "src/feed.ts", + "src/observe.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../activity/activity" }, + { "path": "../../core/agent" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../llm/llm" }, + { "path": "../../runtime-diagnostics/invariants" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/activity-controller/tsconfig.json b/packages/api/activity-controller/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/activity-controller/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/activity-controller/tsdown.config.ts b/packages/api/activity-controller/tsdown.config.ts new file mode 100644 index 0000000000..0552d1b1cf --- /dev/null +++ b/packages/api/activity-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-activity-controller', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true }, +) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0724012e77..05c8dbe032 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -61,6 +61,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", @@ -82,6 +83,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 528d78dc89..388d7e1fb6 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -10,6 +10,7 @@ import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' +import activityRemote from '@deepseek-ai/dsh-api-activity-controller/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -24,6 +25,8 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/remote' export type * from '@deepseek-ai/dsh-api-session-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' +export type {} from '@deepseek-ai/dsh-api-activity-controller/remote' +export type * from '@deepseek-ai/dsh-api-activity-controller/types' export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. @@ -135,7 +138,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, - sessionRemote, workspaceRemote, + sessionRemote, workspaceRemote, activityRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 4d0256e162..7cd03cfd21 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -25,6 +25,7 @@ import type {} from '@deepseek-ai/dsh-settings/types' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-user-questions' export type {} from '@deepseek-ai/dsh-api-session-controller/types' +export type {} from '@deepseek-ai/dsh-api-activity-controller/types' export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' export type { ApiRemoteForwardedEvent } from './types.ts' diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index eb98174378..c24ed4bca5 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -63,6 +63,9 @@ { "path": "../session-controller/tsconfig.client.json" }, + { + "path": "../activity-controller/tsconfig.client.json" + }, { "path": "../workspace-controller/tsconfig.client.json" }, diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 4dd513bdeb..0b0841c2b4 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -54,6 +54,9 @@ { "path": "../session-controller/tsconfig.host.json" }, + { + "path": "../activity-controller/tsconfig.host.json" + }, { "path": "../workspace-controller/tsconfig.host.json" }, diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 981e791fb4..3e2f48264a 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -81,6 +81,11 @@ - id: jobs name: '@deepseek-ai/dsh-jobs-local' + # Live-activity observation registry: non-consuming output streams for the + # optional activity plane (background bash/pwsh mirror into it when loaded). + - id: activity + name: '@deepseek-ai/dsh-activity-local' + - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 80f33257db..e6ad006201 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -41,11 +41,13 @@ "dependencies": { "@deepseek-ai/cordis-plugin-hmr": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", + "@deepseek-ai/dsh-activity-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", + "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment-local": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-command-compact": "workspace:^", "@deepseek-ai/dsh-command-feedback": "workspace:^", @@ -55,19 +57,19 @@ "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", @@ -82,6 +84,7 @@ "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-prompt-llm": "workspace:^", "@deepseek-ai/dsh-settings-file": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-badge": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", @@ -92,13 +95,13 @@ "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", - "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", @@ -106,7 +109,6 @@ "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tool-subagent-report": "workspace:^", - "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", @@ -117,8 +119,7 @@ "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", - "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/dsh-agent-instructions": "workspace:^" + "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 18a0d3911a..9fa12bc33f 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -109,6 +109,10 @@ - id: workspace-controller name: '@deepseek-ai/dsh-api-workspace-controller' + # Live-activity roster and per-activity output observation over Typert Remote. + - id: activity-controller + name: '@deepseek-ai/dsh-api-activity-controller' + # The API gateway: the transport-agnostic dispatch face every client shape # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway @@ -273,6 +277,11 @@ - id: ui-jobs name: '@deepseek-ai/dsh-client-ui-jobs' + # Live activities: the session-header roster with on-demand streaming + # output panels over the activity feed. + - id: ui-activity + name: '@deepseek-ai/dsh-client-ui-activity' + # Goal surface: GoalBar in the input dock over the goal session projection. - id: ui-goal name: '@deepseek-ai/dsh-client-ui-goal' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 68c43da265..0837c9f6b1 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -45,75 +45,77 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-activity": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", - "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-approval": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-brand-official": "workspace:^", - "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", - "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-reference": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", - "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-reference": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", - "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", - "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", + "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", + "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-web-frontend": "workspace:^", - "@deepseek-ai/dsh-host-frontend-static": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0", diff --git a/packages/client/ui-activity/README.i18n.yaml b/packages/client/ui-activity/README.i18n.yaml new file mode 100644 index 0000000000..532ea19b10 --- /dev/null +++ b/packages/client/ui-activity/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-activity/README.md +README.md: 157e98e6b14bc1b556cb2aac8f6f90c43fbcb3f2 +README.zh.md: 392cc91c105caa2f79546f818700da0e6643b8a1 diff --git a/packages/client/ui-activity/README.md b/packages/client/ui-activity/README.md new file mode 100644 index 0000000000..157e98e6b1 --- /dev/null +++ b/packages/client/ui-activity/README.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-activity + +English | [中文](README.zh.md) + +Session-header live-activity list over `ctx.activityFeed`: one header action beside the background-job list renders the roster rows this session can see (owned plus unowned), and expanding a row opens that activity's observation stream into an embedded terminal panel. Observation follows visibility — the stream opens on expand and closes on collapse, unmount, or popover dismissal, so output only flows while someone is watching. Retention gaps and stream failures render as notices above the panel. + +The control renders nothing until the session can see at least one activity, so an ordinary conversation never grows a control for a capability it is not using; without the activity registry the roster stays empty and the entry point never appears. + +## Model Experience + +None, as this package renders host-observed live output for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). + +#### KV Cache effect + +None; the package never assembles or sends provider requests. + +## Known Limitations and Deferred Work + +- **No kill control** — cancellation stays with the model's `job_kill`; a human kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). +- **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up. +- **A background job appears in both header lists** — the job list carries control-facing status, this list carries output; the duplication mirrors the deliberate subagent-catalog precedent. diff --git a/packages/client/ui-activity/README.zh.md b/packages/client/ui-activity/README.zh.md new file mode 100644 index 0000000000..392cc91c10 --- /dev/null +++ b/packages/client/ui-activity/README.zh.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-activity + +[English](README.md) | 中文 + +架在 `ctx.activityFeed` 上的会话头部实时活动列表:在后台任务列表旁提供一个头部 action,渲染本会话可见的 roster 行(有主 + 无主),展开某行即把该 activity 的观察流打开进内嵌终端面板。观察跟随可见性——展开开流,收起、卸载或关闭弹层即停流,只有有人在看时输出才流动。保留缺口与流失败以面板上方的提示呈现。 + +会话一个 activity 都看不到时该控件完全不渲染,普通会话不会为未使用的能力多出控件;没有 activity 注册表时 roster 恒空,入口永不出现。 + +## 模型体验 + +无:本包只为人渲染宿主观察到的实时输出,不触碰任何 prompt、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 + +#### KV Cache 影响 + +无;本包从不组装或发送 provider 请求。 + +## 已知限制与暂缓事项 + +- **没有 kill 控件**——取消仍走模型的 `job_kill`;人工 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。 +- **不渲染通道标签**——stdout 与 stderr chunk 连接成一条流;分通道着色是后续呈现优化。 +- **后台 job 会同时出现在两个头部列表**——job 列表承载控制侧状态,本列表承载输出;这一重复沿用 subagent catalog 的既有取舍。 diff --git a/packages/client/ui-activity/package.json b/packages/client/ui-activity/package.json new file mode 100644 index 0000000000..83395df003 --- /dev/null +++ b/packages/client/ui-activity/package.json @@ -0,0 +1,77 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-activity", + "description": "Session-header live-activity list: roster rows from the activity control stream with on-demand streaming output panels", + "version": "0.1.1-rc.2", + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-activity" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-activity-controller", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-primitives" + ], + "platform": "web" + } + } +} diff --git a/packages/client/ui-activity/src/client/ActivityListAction.module.css b/packages/client/ui-activity/src/client/ActivityListAction.module.css new file mode 100644 index 0000000000..2cf3af6e2f --- /dev/null +++ b/packages/client/ui-activity/src/client/ActivityListAction.module.css @@ -0,0 +1,155 @@ +.root { + position: relative; +} + +.trigger { + display: inline-flex; + align-items: center; + gap: 3px; + min-height: 28px; + padding: 3px 2px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + cursor: pointer; +} + +.trigger:hover, +.trigger:focus-visible { + color: var(--dsw-alias-label-secondary); +} + +.trigger svg { + transition: transform 120ms ease; +} + +.triggerOpen { + transform: rotate(180deg); +} + +.triggerDot { + flex: none; +} + +.count { + margin: 0 5px; +} + +.menu { + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: 100; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 1px; + width: 440px; + max-width: min(520px, calc(100vw - 32px)); + max-height: min(480px, calc(100vh - 140px)); + margin: 0; + padding: 4px; + overflow: auto; + list-style: none; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-specific-menu); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + box-shadow: var(--dsw-shadow-lv3); +} + +.item { + display: flex; + flex-direction: column; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 32px; + padding: 6px 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 18px; + text-align: left; + cursor: pointer; +} + +.row:hover, +.row:focus-visible { + background: var(--dsw-alias-fill-l1); +} + +.rowSettled { + color: var(--dsw-alias-label-tertiary); +} + +.rowDot { + flex: none; +} + +.kind { + flex: none; + padding: 0 6px; + border-radius: 5px; + background: var(--dsw-alias-fill-l2); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 18px; +} + +.label { + flex: 1; + min-width: 0; + overflow: hidden; + font-family: var(--dsw-font-mono); + white-space: nowrap; + text-overflow: ellipsis; +} + +/* A failed activity's detail is the producer's raw text, so it has no bound; + without this it widens the row past the menu instead of eliding like .label. */ +.status { + flex: none; + max-width: 40%; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 18px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.chevronOpen { + transform: rotate(180deg); +} + +.panel { + margin: 0 8px 8px; +} + +.notice { + margin: 0 8px 6px; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + +.noticeError { + color: var(--dsw-alias-label-error, var(--dsw-alias-label-secondary)); +} diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-activity/src/client/ActivityListAction.tsx new file mode 100644 index 0000000000..698815e171 --- /dev/null +++ b/packages/client/ui-activity/src/client/ActivityListAction.tsx @@ -0,0 +1,250 @@ +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import type { + ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, +} from '@deepseek-ai/dsh-api-activity-controller/client' +import { + IconChevronDownOutline14, StateDot, TerminalBlock, useDismissOnOutsidePointer, + type StateDotState, type TerminalBlockLabels, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import css from './ActivityListAction.module.css' + +/** Registration-side business face for the activity feed. */ +export interface ActivityListInjected { + hooks: { + /** Client activity snapshot bound by the renderer as useActivity. */ + activity: { + getSnapshot(): ActivityFeedSnapshot + subscribe(listener: () => void): () => void + } + } + /** + * Start observing one activity's live output; returns the stop function. + * Reference-counted by the client feed, so panels can overlap safely. + */ + observe: (id: ActivityId) => () => void +} + +/** Full props for the session-header live-activity action. */ +export type ActivityListActionProps = + PropsRuntime<'conversation.session.header.actions'> + & PropsLocale + & InjectFace + +/** Stable empty list so a session with no activities keeps one array identity. */ +const NO_ROWS: readonly ActivityRow[] = [] + +/** Height cap for one live output panel inside the popover. */ +const PANEL_MAX_LINES = 16 + +function isLive(row: ActivityRow): boolean { + return row.status === 'running' +} + +/** Closed-union exhaustiveness fence for the wire status set. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ +function assertNever(value: never): never { + throw new Error(`unhandled activity status: ${JSON.stringify(value)}`) +} + +function dotState(status: ActivityRow['status']): StateDotState { + switch (status) { + case 'running': return 'ongoing' + case 'completed': return 'done' + case 'killed': return 'warning' + case 'failed': return 'error' + /* v8 ignore next -- closed wire status union */ + default: return assertNever(status) + } +} + +function statusLabel(status: ActivityRow['status'], t: TranslateNS): string { + switch (status) { + case 'running': return t('status.running') + case 'completed': return t('status.completed') + case 'killed': return t('status.killed') + case 'failed': return t('status.failed') + /* v8 ignore next -- closed wire status union */ + default: return assertNever(status) + } +} + +/** Localized display copy for the embedded terminal panel. */ +function terminalLabels(t: TranslateNS): TerminalBlockLabels { + return { + // The labels contract requires exit-fact formatters, but this panel never + // passes exit facts, so TerminalBlock never invokes them. + /* v8 ignore next */ + signal: signal => t('terminal.signal', { signal }), + /* v8 ignore next */ + exitCode: code => t('terminal.exitCode', { code }), + running: t('terminal.running'), + failed: t('terminal.failed'), + done: t('terminal.done'), + copy: t('terminal.copy'), + copied: t('terminal.copied'), + noOutput: t('terminal.noOutput'), + collapseAria: t('terminal.collapseAria'), + collapse: t('terminal.collapse'), + expandAria: hidden => t('terminal.expandAria', { n: hidden }), + expand: hidden => t('terminal.expand', { n: hidden }), + } +} + +/* jscpd:ignore-start -- mirrors JobListAction's ordering and trigger chrome by design so the two header lists read as one family. */ +/** Live rows first in start order, then settled rows newest-first. */ +function ordered(rows: readonly ActivityRow[]): ActivityRow[] { + return [...rows].sort((left, right) => { + const liveLeft = isLive(left) + if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 + if (liveLeft) return left.startedAt - right.startedAt + const finished = (right.finishedAt ?? right.startedAt) - (left.finishedAt ?? left.startedAt) + return finished !== 0 ? finished : left.startedAt - right.startedAt + }) +} +/* jscpd:ignore-end */ + +/** One expandable roster row plus its on-demand live output panel. */ +function ActivityItem({ row, view, expanded, onToggle, t }: { + row: ActivityRow + view: ObservedActivity | undefined + expanded: boolean + onToggle: () => void + t: TranslateNS +}) { + const live = isLive(row) + const status = statusLabel(row.status, t) + const labels = useMemo(() => terminalLabels(t), [t]) + return ( +
  • + + {expanded + ? ( +
    + {view?.gapBefore === true ?
    {t('output.gap')}
    : null} + {view?.error !== undefined + ?
    {t('output.error', { error: view.error })}
    + : null} + +
    + ) + : null} +
  • + ) +} + +/** + * Session-header entry point for this session's live activities. It renders + * nothing at all until the session can see at least one activity, expanding a + * row starts its observation stream, and collapsing (or closing the popover) + * stops it — output only flows while someone is watching. + * @param props - runtime slot currency, the activity snapshot hook, the + * observation control, and the namespace translator. + * @returns the trigger and its popover list, or null when there is nothing to show. + */ +export function ActivityListAction({ sessionId, useActivity, observe, t }: ActivityListActionProps) { + const owned = useActivity(state => state.rowsBySession[sessionId]) ?? NO_ROWS + const unowned = useActivity(state => state.rowsBySession['']) ?? NO_ROWS + const observedViews = useActivity(state => state.observed) + const [open, setOpen] = useState(false) + const [expandedId, setExpandedId] = useState(undefined) + const rootRef = useRef(null) + const triggerRef = useRef(null) + + const rows = useMemo(() => ordered([...owned, ...unowned]), [owned, unowned]) + const liveCount = useMemo(() => rows.filter(isLive).length, [rows]) + + useDismissOnOutsidePointer(rootRef, open, setOpen) + + // Observation follows visibility: the stream opens when a panel expands and + // closes when it collapses, unmounts, or the popover closes. + const activeId = open ? expandedId : undefined + useEffect(() => { + if (activeId === undefined) return + return observe(activeId as ActivityId) + }, [activeId, observe]) + + // The last activity disappearing removes this control; close first so focus + // does not vanish from an unmounting node. + useEffect(() => { + if (rows.length === 0 && open) setOpen(false) + }, [rows.length, open]) + + // An expanded row that left the roster (owner disposal) folds its panel. + useEffect(() => { + if (expandedId !== undefined && !rows.some(row => String(row.id) === expandedId)) { + setExpandedId(undefined) + } + }, [rows, expandedId]) + + if (rows.length === 0) return null + + const countKey = liveCount > 0 + ? (liveCount === 1 ? 'count.live.one' : 'count.live.other') + : (rows.length === 1 ? 'count.idle.one' : 'count.idle.other') + const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : rows.length }) + + /* jscpd:ignore-start -- mirrors JobListAction's dismiss/trigger interaction by design. */ + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || !open) return + event.preventDefault() + setOpen(false) + triggerRef.current?.focus() + } + + return ( +
    + + {/* jscpd:ignore-end */} + {open + ? ( +
      + {rows.map(row => ( + { + setExpandedId(current => current === String(row.id) ? undefined : String(row.id)) + }} + t={t} + /> + ))} +
    + ) + : null} +
    + ) +} diff --git a/packages/client/ui-activity/src/client/index.ts b/packages/client/ui-activity/src/client/index.ts new file mode 100644 index 0000000000..83ce0aa7bf --- /dev/null +++ b/packages/client/ui-activity/src/client/index.ts @@ -0,0 +1,48 @@ +/** + * Live-activity plugin, browser half: contributes one session-header action + * that renders the activity roster and, per expanded row, an on-demand + * observation stream's live output. All data arrives through the + * `activityFeed` client service; this plugin holds no transport state. + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { ActivityListAction } from './ActivityListAction.tsx' +import type { ActivityListInjected } from './ActivityListAction.tsx' +import type {} from '@deepseek-ai/dsh-api-activity-controller/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import { en, NS, zh, type ActivityKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Live-activity list copy. */ + 'activity': ActivityKey + } +} + +export type { ActivityListActionProps, ActivityListInjected } from './ActivityListAction.tsx' + +/** Required services: the activity feed, the slot registry, and dictionaries. */ +export const inject = ['activityFeed', 'slots', 'locale'] + +/** + * Client plugin body: register the dictionaries and the header action. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-activity: dictionaries') + ctx.slots.inject( + 'conversation.session.header.actions', + () => ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'activity-list', + // After the background-job list: job control reads before live output. + order: 30, + locale: NS, + inject: (): ActivityListInjected => ({ + hooks: { activity: ctx.activityFeed.state }, + observe: id => ctx.activityFeed.observe(id), + }), + }, ActivityListAction), + ) +} diff --git a/packages/client/ui-activity/src/client/locales.ts b/packages/client/ui-activity/src/client/locales.ts new file mode 100644 index 0000000000..efea5126da --- /dev/null +++ b/packages/client/ui-activity/src/client/locales.ts @@ -0,0 +1,65 @@ +/** `activity` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'activity' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'count.live.one': '{count} 个活动进行中', + 'count.live.other': '{count} 个活动进行中', + 'count.idle.one': '{count} 个活动', + 'count.idle.other': '{count} 个活动', + 'list.aria': '实时活动', + 'row.expandAria': '展开 {label} 的实时输出', + 'row.collapseAria': '收起 {label} 的实时输出', + 'status.running': '运行中', + 'status.completed': '已完成', + 'status.killed': '已取消', + 'status.failed': '已失败', + 'output.gap': '……较早的输出已丢弃……', + 'output.error': '实时输出流中断:{error}', + 'terminal.signal': '信号 {signal}', + 'terminal.exitCode': '退出码 {code}', + 'terminal.running': '运行中', + 'terminal.failed': '已失败', + 'terminal.done': '已完成', + 'terminal.copy': '复制', + 'terminal.copied': '已复制', + 'terminal.noOutput': '(无输出)', + 'terminal.collapse': '收起', + 'terminal.collapseAria': '收起输出', + 'terminal.expand': '展开其余 {n} 行', + 'terminal.expandAria': '展开被折叠的 {n} 行输出', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'count.live.one': '{count} live activity', + 'count.live.other': '{count} live activities', + 'count.idle.one': '{count} activity', + 'count.idle.other': '{count} activities', + 'list.aria': 'Live activities', + 'row.expandAria': 'Show live output of {label}', + 'row.collapseAria': 'Hide live output of {label}', + 'status.running': 'running', + 'status.completed': 'completed', + 'status.killed': 'cancelled', + 'status.failed': 'failed', + 'output.gap': '… earlier output dropped …', + 'output.error': 'live output stream interrupted: {error}', + 'terminal.signal': 'signal {signal}', + 'terminal.exitCode': 'exit {code}', + 'terminal.running': 'running', + 'terminal.failed': 'failed', + 'terminal.done': 'done', + 'terminal.copy': 'Copy', + 'terminal.copied': 'Copied', + 'terminal.noOutput': '(no output)', + 'terminal.collapse': 'Collapse', + 'terminal.collapseAria': 'Collapse output', + 'terminal.expand': 'Show {n} more lines', + 'terminal.expandAria': 'Expand {n} collapsed output lines', +} + +/** Key domain of the `activity` namespace (zh is the source of truth). */ +export type ActivityKey = keyof typeof zh diff --git a/packages/client/ui-activity/src/css-modules.d.ts b/packages/client/ui-activity/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-activity/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-activity/src/index.ts b/packages/client/ui-activity/src/index.ts new file mode 100644 index 0000000000..f75578ddaf --- /dev/null +++ b/packages/client/ui-activity/src/index.ts @@ -0,0 +1,9 @@ +/** + * Node half of the live-activity list plugin. The browser half in + * `src/client/` owns every contribution; this entry exists so the package + * appears as an ordinary Loader row. + * @module @deepseek-ai/dsh-client-ui-activity + */ + +/** Loader-visible no-op body; the browser half carries the feature. */ +export function apply(): void {} diff --git a/packages/client/ui-activity/src/invariant.ts b/packages/client/ui-activity/src/invariant.ts new file mode 100644 index 0000000000..22eba24c42 --- /dev/null +++ b/packages/client/ui-activity/src/invariant.ts @@ -0,0 +1,27 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-activity`. + * @module @deepseek-ai/dsh-client-ui-activity/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-activity' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-activity-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: pure presentation over the activity feed's client snapshot; the feed's own packages assert the data relations. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx new file mode 100644 index 0000000000..96156c798d --- /dev/null +++ b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx @@ -0,0 +1,244 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { + ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, +} from '@deepseek-ai/dsh-api-activity-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { ActivityListAction, type ActivityListActionProps } from '../src/client/ActivityListAction.tsx' +import { zh } from '../src/client/locales.ts' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +const SESSION = 'session' as SessionId +const t: ActivityListActionProps['t'] = makeTranslate(zh) + +function row(over: Partial = {}): ActivityRow { + return { + id: 'bash-1' as ActivityId, + kind: 'bash', + label: 'pnpm run build', + sessionId: SESSION, + status: 'running', + startedAt: 1_700_000_000_000, + outputTotal: 0, + ...over, + } +} + +/** A row with no owner session, as the unowned bucket carries them. */ +function unownedRow(over: Partial = {}): ActivityRow { + const { sessionId: _drop, ...rest } = row(over) + return rest +} + +function props( + snapshot: Partial, + observe: ActivityListActionProps['observe'] = () => () => {}, +): ActivityListActionProps { + const state: ActivityFeedSnapshot = { + rowsBySession: {}, + observed: {}, + phase: 'ready', + ...snapshot, + } + function useActivity(select: (value: ActivityFeedSnapshot) => T): T { + return select(state) + } + return { sessionId: SESSION, useActivity, observe, t } as unknown as ActivityListActionProps +} + +function openList(): void { + fireEvent.click(screen.getAllByRole('button')[0]!) +} + +describe('ActivityListAction visibility', () => { + it('renders nothing while the session sees no activities', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('counts live activities on the trigger, merging owned and unowned rows', () => { + render() + expect(screen.getByRole('button', { name: '2 个活动进行中' })).toBeDefined() + }) + + it('falls back to the total when nothing is live and hides foreign sessions', () => { + render() + expect(screen.getByRole('button', { name: '1 个活动' })).toBeDefined() + }) +}) + +describe('ActivityListAction rows and observation', () => { + it('lists rows with kind, label, and detail-or-status', () => { + render() + openList() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + const items = within(list).getAllByRole('listitem') + expect(items).toHaveLength(2) + expect(items[0]?.textContent).toContain('pnpm run build') + expect(items[0]?.textContent).toContain(zh['status.running']) + expect(items[1]?.textContent).toContain('exit code: 3') + }) + + it('marks killed rows and expands an overflowing panel through the collapse controls', () => { + const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n') + const view: ObservedActivity = { + activityId: 'bash-1' as ActivityId, + text: lines, + gapBefore: false, + status: 'killed', + streaming: false, + } + render() + openList() + expect(screen.getByText(zh['status.killed'])).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + const expand = screen.getByRole('button', { name: zh['terminal.expandAria'].replace('{n}', '8') }) + fireEvent.click(expand) + expect(screen.getByRole('button', { name: zh['terminal.collapseAria'] })).toBeDefined() + }) + + it('orders live rows first by start and settled rows newest-first with tie-breaks', () => { + const settled = (id: string, label: string, startedAt: number, finishedAt?: number): ActivityRow => + row({ + id: id as ActivityId, + label, + status: 'completed', + startedAt, + ...finishedAt !== undefined ? { finishedAt } : {}, + }) + render() + openList() + const labels = within(screen.getByRole('list', { name: zh['list.aria'] })) + .getAllByRole('listitem') + .map(item => item.querySelector('[title]')?.getAttribute('title')) + expect(labels).toEqual([ + 'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e', + ]) + expect(screen.getAllByText(zh['status.completed']).length).toBeGreaterThan(0) + }) + + it('ignores other keys while open and closes once the roster empties', () => { + const settledPair = [ + row({ id: 'bash-1' as ActivityId, status: 'completed', finishedAt: 2 }), + row({ id: 'bash-2' as ActivityId, status: 'completed', finishedAt: 3 }), + ] + const { rerender } = render() + expect(screen.getByRole('button', { name: '2 个活动' })).toBeDefined() + openList() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + fireEvent.keyDown(list, { key: 'a' }) + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() + // The roster emptying unmounts the control entirely; the open flag resets first. + rerender() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('starts observing on expand, renders the live text, and stops on collapse', () => { + const stop = vi.fn() + const observe = vi.fn(() => stop) + const view: ObservedActivity = { + activityId: 'bash-1' as ActivityId, + text: 'compiling…\n', + gapBefore: false, + status: 'running', + streaming: true, + } + render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(observe).toHaveBeenCalledWith('bash-1') + expect(screen.getByText('compiling…')).toBeDefined() + + fireEvent.click(screen.getByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'pnpm run build') })) + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('surfaces retention gaps and stream failures above the panel', () => { + const view: ObservedActivity = { + activityId: 'bash-1' as ActivityId, + text: 'tail only', + gapBefore: true, + status: 'running', + streaming: false, + error: 'connection lost', + } + render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(screen.getByText(zh['output.gap'])).toBeDefined() + expect(screen.getByText('实时输出流中断:connection lost')).toBeDefined() + }) + + it('stops observation when the popover closes via Escape', () => { + const stop = vi.fn() + render( stop)} />) + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + fireEvent.keyDown(screen.getByRole('list', { name: zh['list.aria'] }), { key: 'Escape' }) + expect(stop).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull() + }) + + it('folds an expanded panel whose row left the roster', () => { + const stop = vi.fn() + // One stable observe identity across renders, as the inject face provides. + const observe = () => stop + const first = props({ rowsBySession: { [SESSION]: [row()] } }, observe) + const { rerender } = render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + rerender() + expect(stop).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull() + }) +}) diff --git a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts b/packages/client/ui-activity/tests/browser-plugin.client.spec.ts new file mode 100644 index 0000000000..de6db2a069 --- /dev/null +++ b/packages/client/ui-activity/tests/browser-plugin.client.spec.ts @@ -0,0 +1,115 @@ +/** + * ui-activity plugin halves: the browser entry's dictionary and header-slot + * registrations against the real SlotRegistry (with fiber teardown proving + * removal — HMR safety), the inert node entry, and the invariant companion's + * ownership reservation. + */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '../src/client/index.ts' +import { apply as applyNode } from '../src/index.ts' +import * as ActivityInvariant from '../src/invariant.ts' +import { en, NS, zh } from '../src/client/locales.ts' + +/** Slot ledger reader: entry ids currently registered in the header list. */ +function headerEntryIds(ctx: Context): (string | undefined)[] { + return ctx.slots + .entries('conversation.session.header.actions') + .map(entry => entry.options.id) +} + +/** Ids handed to the stubbed feed's observe control. */ +const observed: string[] = [] + +/** Boot the browser half over a real slot tree that declares the header list. */ +async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + ctx.slots.register({ + name: 'root', + children: { + 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + }, + } as never, () => null) + ctx.provide('activityFeed', { + state: { getSnapshot: () => ({ rowsBySession: {}, observed: {}, phase: 'pending' }), subscribe: () => () => {} }, + observe: (id: string) => { observed.push(id); return () => {} }, + } as never) + // The locale plugin binds a settings scope, which reads the connection handle + // and the forwarded-event port. + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + // These specs assert the shipped Chinese copy. There is no jsdom `window` in + // this lane, so browser-language detection never runs and the locale comes + // from FALLBACK_LOCALE (en): state the asserted locale explicitly. + ctx.locale.setLocale('zh') + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + return { ctx, fiber } +} + +describe('ui-activity browser half', () => { + it('declares the services it binds', () => { + expect(inject).toEqual(['activityFeed', 'slots', 'locale']) + }) + + it('exposes the feed source and observation control through the inject face', async () => { + const { ctx } = await bench() + const entry = ctx.slots + .entries('conversation.session.header.actions') + .find(candidate => candidate.options.id === 'activity-list') + const face = (entry as unknown as { + inject: () => { hooks: { activity: unknown }; observe: (id: string) => () => void } + }).inject() + expect(face.hooks.activity).toBeDefined() + const stopper = face.observe('bash-1') + expect(observed).toEqual(['bash-1']) + stopper() + }) + + it('registers the header action, and fiber teardown removes it (HMR safety)', async () => { + const { ctx, fiber } = await bench() + expect(headerEntryIds(ctx)).toContain('activity-list') + await fiber.dispose() + expect(headerEntryIds(ctx)).not.toContain('activity-list') + }) + + it('registers both dictionaries under its own namespace and releases them with the fiber', async () => { + const { ctx, fiber } = await bench() + const translate = ctx.locale.bind(NS) + expect(translate('list.aria')).toBe(zh['list.aria']) + ctx.locale.setLocale('en') + expect(translate('list.aria')).toBe(en['list.aria']) + + // Withdrawn dictionaries leave the key unresolved rather than translated. + await fiber.dispose() + expect(translate('list.aria')).not.toBe(en['list.aria']) + }) + + it('keeps the English dictionary key-identical to the Chinese source of truth', () => { + expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) + }) +}) + +describe('ui-activity node half', () => { + it('contributes no host behavior', () => { + // The node half exists only so the plugin appears in the Loader tree. + expect(applyNode).not.toThrow() + }) +}) + +describe('ui-activity invariant companion', () => { + it('reserves package ownership under its declared companion name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + await ctx.plugin(ActivityInvariant) + expect(ActivityInvariant.name).toBe('client-ui-activity-invariant') + expect(ActivityInvariant.inject).toEqual(['invariants']) + }) +}) diff --git a/packages/client/ui-activity/tsconfig.json b/packages/client/ui-activity/tsconfig.json new file mode 100644 index 0000000000..bbd9c5ce85 --- /dev/null +++ b/packages/client/ui-activity/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../api/activity-controller/tsconfig.client.json" }, + { "path": "../locale" }, + { "path": "../ui-conversation" }, + { "path": "../ui-primitives" }, + { "path": "../ui-renderer" }, + { "path": "../ui-session" }, + { "path": "../ui-slots" }, + { "path": "../../runtime-diagnostics/invariants" } + ] +} diff --git a/packages/client/ui-activity/tsdown.config.ts b/packages/client/ui-activity/tsdown.config.ts new file mode 100644 index 0000000000..096771fe90 --- /dev/null +++ b/packages/client/ui-activity/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-client-ui-activity', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 720136b270..a025628aa4 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: c1c40e39710d46fae240f0b3281c36d660855010 -README.zh.md: 631936ad658c147923e5f80f2d7445cac93d6b36 +README.md: 4df368b270511776896c74a55b0c1d2e684035d0 +README.zh.md: 77830a1f162e963709ea4d4d25c8393e7dd97d66 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c1c40e3971..4df368b270 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -18,7 +18,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P ## Terminal output -`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; a running block with a supplied `output` renders that live text under the running state (an undefined output keeps the historical prompt-only frame); it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). ## Read rendering diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 631936ad65..77830a1f16 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -18,7 +18,7 @@ ## 终端输出 -`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)。 +`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;运行中且提供了 `output` 的块会在 running 状态下渲染这段实时文本(未提供 output 则保持历史上的仅提示行画面);由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)。 ## Read 渲染 diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index 26e26fe2d2..8f87db9741 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -54,7 +54,10 @@ export interface TerminalBlockProps { exitCode?: number | undefined /** Settled terminating signal name; any value renders the status pill, taking precedence over the exit code. */ signal?: string | undefined - /** The command is still running: the block shows the prompt line alone. */ + /** + * The command is still running: with no `output` the block shows the prompt + * line alone; with output it renders the live text under the running state. + */ running?: boolean | undefined /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */ maxLines?: number | undefined @@ -221,13 +224,16 @@ export function TerminalBlock({ ))} {status !== undefined && {status}} - {!running && !empty && ( + {(!running || output !== undefined) && !empty && ( )} - {!running && (empty + {/* A running block renders its live text as soon as the caller supplies + output; an undefined output keeps the historical prompt-only frame, + and a live-but-empty stream draws no placeholder. */} + {(!running || (output !== undefined && !empty)) && (empty && !running ?
    {copy.noOutput}
    : (
    diff --git a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx index 45de33c8f0..886661b396 100644 --- a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx @@ -89,15 +89,30 @@ describe('TerminalBlock prompt label', () => { }) describe('TerminalBlock states', () => { - it('running shows the command line only: no output, no placeholder, no copy', () => { - const view = render() + it('running with no output shows the command line only: no output box, no placeholder, no copy', () => { + const view = render() expect(view.getByText('sleep 5')).toBeTruthy() - expect(view.queryByText('partial')).toBeNull() + expect(outputLines(view.container)).toEqual([]) expect(view.queryByText('无输出')).toBeNull() expect(view.queryByRole('button')).toBeNull() expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('') }) + it('running with supplied output streams the live text and keeps it copyable', () => { + const view = render() + expect(view.getByText('partial')).toBeTruthy() + expect(view.queryByText('无输出')).toBeNull() + expect(view.queryByRole('button', { name: '复制' })).toBeTruthy() + expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('') + }) + + it('running with an empty live stream draws neither output nor placeholder', () => { + const view = render() + expect(outputLines(view.container)).toEqual([]) + expect(view.queryByText('无输出')).toBeNull() + expect(view.queryByRole('button')).toBeNull() + }) + it('running still shows a settled-looking status pill when one is supplied', () => { render() expect(screen.getByText('信号 SIGINT')).toBeTruthy() diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e038b24a0d..1a53958b6c 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1141,6 +1141,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ + 'client-ui-activity ActivityListAction id \'activity-list\'', 'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'', 'client-ui-jobs JobListAction id \'job-list\'', ], diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 320c44b9aa..35c3b93d92 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -81,6 +81,68 @@ export interface TypeApiEntry { /** Every harness `ctx.` service, sorted by key. */ export const SERVICE_API: readonly ServiceApiEntry[] = [ + { + key: 'activities', + summary: 'Abstract streaming-output registry.', + description: 'Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected.\n- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource.\n- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap.\n- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it. Every listener is contained.', + methods: [ + { + signature: 'abstract open(spec: ActivityOpen): ActivityHandle', + description: 'Validate the spec, attach owner cleanup, and atomically register one activity. A rejection leaves no id; after return the producer owns the handle and the visible set has changed.', + parameters: [{ name: 'spec', description: 'activity identity, owner, and correlation.' }], + returns: 'the producer face of the registered activity.', + }, + { + signature: 'abstract list(caller?: Agent): ActivitySnapshot[]', + description: 'List caller-owned and unowned activities in registration order without exposing another session\'s labels or output.', + parameters: [{ name: 'caller', description: 'reading agent; a non-agent caller sees only unowned activities.' }], + returns: 'fresh snapshots.', + }, + { + signature: 'abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot', + description: 'Return a fresh snapshot. Throws for an unknown or foreign activity.', + parameters: [{ name: 'id', description: 'activity to look up.' }, { name: 'caller', description: 'reading agent checked against the owner.' }], + returns: 'a fresh snapshot.', + }, + { + signature: 'abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead', + description: 'Read retained output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Throws for an unknown or foreign activity or a negative or non-integer offset.', + parameters: [{ name: 'id', description: 'activity to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }], + returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.', + }, + { + signature: 'abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void', + description: 'Register an effect-scoped observer of visible-set changes: opening, detail updates, settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas. Listeners are contained and never awaited.', + parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned activity changed and every caller\'s set did.' }], + returns: 'disposer that unregisters the listener.', + }, + { + signature: 'abstract onOutput(listener: ActivityOutputListener): () => void', + description: 'Register an effect-scoped observer of stream advancement — one signal per committed append and one at settlement, carrying only the activity id. A consumer schedules a read from its own cursor; the registry never pushes payloads. Listeners are contained and never awaited.', + parameters: [{ name: 'listener', description: 'receives the id whose stream advanced.' }], + returns: 'disposer that unregisters the listener.', + }, + ], + }, + { + key: 'activityController', + summary: 'Host service backing the generated `ctx.remote.activity` namespace.', + description: 'Host service backing the generated `ctx.remote.activity` namespace.', + methods: [ + { + signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable', + description: 'Stream a complete roster baseline followed by whole-bucket replacements.', + parameters: [{ name: 'signal', description: 'generation cancellation.' }], + returns: 'baseline followed by per-owner roster replacement frames.', + }, + { + signature: '@Remote({ mode: \'stream\' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable', + description: 'Stream one activity\'s retained output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursors never observe these reads.', + parameters: [{ name: 'request', description: 'target activity and optional resume offset.' }, { name: 'signal', description: 'generation cancellation.' }], + returns: 'anchor, coalesced output frames, and the terminal status.', + }, + ], + }, { key: 'agentDefaultModel', summary: 'Owns the default model selection independently of any Host or transport.', @@ -3115,6 +3177,94 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the Service and Event signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ + { + name: 'ActivitiesChangedListener', + declaration: 'export type ActivitiesChangedListener = (owner: Agent | undefined) => void;', + }, + { + name: 'ActivityAppendOptions', + declaration: 'export interface ActivityAppendOptions {\n channel?: ActivityChannel;\n gapBefore?: true;\n}', + }, + { + name: 'ActivityChannel', + declaration: 'export type ActivityChannel = \'stdout\' | \'stderr\';', + }, + { + name: 'ActivityControlFrame', + declaration: 'export type ActivityControlFrame = {\n readonly type: \'baseline\';\n readonly activities: readonly ActivityRow[];\n} | {\n readonly type: \'rows\';\n readonly sessionId?: SessionId;\n readonly activities: readonly ActivityRow[];\n};', + }, + { + name: 'ActivityCorrelation', + declaration: 'export interface ActivityCorrelation {\n callId?: CallId;\n jobId?: JobId;\n}', + }, + { + name: 'ActivityHandle', + declaration: 'export interface ActivityHandle {\n readonly id: ActivityId;\n append(text: string, options?: ActivityAppendOptions): void;\n updateDetail(detail: string): void;\n end(outcome: ActivityOutcome): void;\n}', + }, + { + name: 'ActivityId', + declaration: 'export type ActivityId = Branded<\'ActivityId\'>;', + }, + { + name: 'ActivityKind', + declaration: 'export type ActivityKind = ActivityKindMap[keyof ActivityKindMap];', + }, + { + name: 'ActivityKindMap', + declaration: 'export interface ActivityKindMap {\n bash: \'bash\';\n}', + }, + { + name: 'ActivityObserveFrame', + declaration: 'export type ActivityObserveFrame = {\n readonly type: \'opened\';\n readonly activityId: ActivityId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly ActivityWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};', + }, + { + name: 'ActivityObserveRequest', + declaration: 'export interface ActivityObserveRequest {\n readonly activityId: ActivityId;\n readonly from?: number;\n}', + }, + { + name: 'ActivityOpen', + declaration: 'export interface ActivityOpen {\n kind: ActivityKind;\n label: string;\n owner?: Agent;\n correlation?: ActivityCorrelation;\n}', + }, + { + name: 'ActivityOutcome', + declaration: 'export interface ActivityOutcome {\n status: \'completed\' | \'failed\' | \'killed\';\n detail?: string;\n}', + }, + { + name: 'ActivityOutputChunk', + declaration: 'export interface ActivityOutputChunk {\n at: number;\n text: string;\n channel?: ActivityChannel;\n gapBefore?: true;\n}', + }, + { + name: 'ActivityOutputListener', + declaration: 'export type ActivityOutputListener = (id: ActivityId) => void;', + }, + { + name: 'ActivityRead', + declaration: 'export interface ActivityRead {\n chunks: readonly ActivityOutputChunk[];\n next: number;\n lossy: boolean;\n}', + }, + { + name: 'ActivityRow', + declaration: 'export interface ActivityRow {\n readonly id: ActivityId;\n readonly kind: string;\n readonly label: string;\n readonly sessionId?: SessionId;\n readonly correlation?: ActivityRowCorrelation;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal: number;\n}', + }, + { + name: 'ActivityRowCorrelation', + declaration: 'export interface ActivityRowCorrelation {\n readonly callId?: CallId;\n readonly jobId?: JobId;\n}', + }, + { + name: 'ActivityRowStatus', + declaration: 'export type ActivityRowStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';', + }, + { + name: 'ActivitySnapshot', + declaration: 'export interface ActivitySnapshot {\n id: ActivityId;\n kind: ActivityKind;\n label: string;\n ownerSession?: SessionId;\n correlation?: ActivityCorrelation;\n status: ActivityStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n outputTotal: number;\n outputEarliest: number;\n}', + }, + { + name: 'ActivityStatus', + declaration: 'export type ActivityStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';', + }, + { + name: 'ActivityWireChunk', + declaration: 'export interface ActivityWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', + }, { name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', @@ -4827,9 +4977,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ShellExecSpec', declaration: 'export interface ShellExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy: SandboxExecutionPolicy | undefined;\n}', }, + { + name: 'ShellObservedStreams', + declaration: 'export interface ShellObservedStreams {\n stdout?: SubprocessOutputReader;\n stderr?: SubprocessOutputReader;\n}', + }, { name: 'ShellProcess', - declaration: 'export interface ShellProcess {\n status: ShellProcessStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise;\n sandbox?: ShellSandboxInfo;\n readOutput(): ShellProcessRead;\n kill(): boolean;\n}', + declaration: 'export interface ShellProcess {\n status: ShellProcessStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise;\n sandbox?: ShellSandboxInfo;\n readOutput(): ShellProcessRead;\n observed?: ShellObservedStreams;\n kill(): boolean;\n}', }, { name: 'ShellProcessRead', diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index 8129e170ed..4550338e3b 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md -README.md: f4469286603f687be5789bd016ab462dba26e664 -README.zh.md: 6fd97aa3fe4cbbd555c2475f4132ec00c6a97390 +README.md: 1ed69b99ae10d4821e134b42f2ae19ad4ac3350b +README.zh.md: c9fd920bb552e0386ed0644fe00c4473f5b95e3c diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index f446928660..1ed69b99ae 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -35,6 +35,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Stream output has one consuming cursor** — independent observers need a cursor or snapshot API. +- **Stream output has one consuming cursor** — the model owns it; independent observers use the non-consuming [`dsh-activity`](../../activity/activity/README.md) plane the producers mirror into instead. - **Foreground work cannot be promoted** — producers choose foreground or background before starting. - **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam. diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index 6fd97aa3fe..c9fd920bb5 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -35,6 +35,6 @@ ## 已知限制与暂缓事项 -- **流输出只有一个消费游标**:独立观察者需要游标或快照 API。 +- **流输出只有一个消费游标**:它属于模型;独立观察者改用生产者镜像进去的非消费 [`dsh-activity`](../../activity/activity/README.zh.md) 观察面。 - **前台工作无法转为后台**:生产方在启动前选择前台或后台。 - **约定是进程内的**:`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index 6c37c5b794..fa4f5ef893 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -272,6 +272,7 @@ export class LocalBashExecutor extends ShellExecutor { status: 'running', exitCode: null, signal: null, + observed: collected, done: running.done.then((outcome) => { // Any signal termination is killed, including a command signaling itself. if (proc.status === 'running') { diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index b7a2d9f915..08abd61169 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -301,6 +301,7 @@ export class PwshLocalExecutor extends ShellExecutor { status: 'running', exitCode: null, signal: null, + observed: collected, done: running.done.then((outcome) => { // Any signal termination is killed, including a command signaling itself. if (proc.status === 'running') { diff --git a/packages/shell/shell/README.i18n.yaml b/packages/shell/shell/README.i18n.yaml index 5317414e55..bcf293269a 100644 --- a/packages/shell/shell/README.i18n.yaml +++ b/packages/shell/shell/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/shell/README.md -README.md: a15980ead797a73c18dd6d8825a4b38cf845df60 -README.zh.md: 008b19835697d5d9514cf5472696852cfc684acd +README.md: 45a4d0c47e7604e7ad2ffc9f98a387cfa4d20b0c +README.zh.md: da869b78981df6bcfc253e84f08e4ca63cbff5f3 diff --git a/packages/shell/shell/README.md b/packages/shell/shell/README.md index a15980ead7..45a4d0c47e 100644 --- a/packages/shell/shell/README.md +++ b/packages/shell/shell/README.md @@ -23,6 +23,7 @@ The split is a standard capability seam ([capability-seams Agent Note](../../../ | `start(spec)` | Background execution. Returns a task-free `ShellProcess` handle immediately; **no timeout applies**. The caller may adapt it into `ctx.jobs`. | | `sandboxMode` | The capability fact for the tool layer: the default mode a SANDBOXING executor confines under (`undefined` in the base class — "this executor does not sandbox"). `dsh-tool-bash` reads it at registration to advertise the escalation fields only when the composition honors them. | | `ShellProcess.readOutput()` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. | +| `ShellProcess.observed` | Optional **non-consuming** offset readers over the same captured streams, for observers independent of the consuming cursor (the activity observation plane). Absent when a backend cannot expose offset reads. | | `ShellProcess.kill()` | Kill the process group. Returns `false` when it already finished. | Implementations subclass `ShellExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit. diff --git a/packages/shell/shell/README.zh.md b/packages/shell/shell/README.zh.md index 008b198356..da869b7898 100644 --- a/packages/shell/shell/README.zh.md +++ b/packages/shell/shell/README.zh.md @@ -23,6 +23,7 @@ | `start(spec)` | 后台执行。立即返回不含任务语义的 `ShellProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.jobs`。 | | `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 | | `ShellProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区容量限制而丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 | +| `ShellProcess.observed` | 可选的**非消费** offset 读取器,覆盖同一批捕获流,供独立于消费游标的观察者使用(activity 观察面)。后端无法提供 offset 读取时缺省。 | | `ShellProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 | 实现会继承 `ShellExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出。 diff --git a/packages/shell/shell/src/types.ts b/packages/shell/shell/src/types.ts index 21fea2d791..1de1cabf95 100644 --- a/packages/shell/shell/src/types.ts +++ b/packages/shell/shell/src/types.ts @@ -8,10 +8,22 @@ */ import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' -import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess' +import type { CollectedOutput, DshEnvironment, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' -export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess' +export type { CollectedOutput, DshEnvironment, DshEnvironmentKey, SubprocessOutputRead, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' + +/** + * Non-consuming offset readers over a background process's captured streams, + * for observers independent of the consuming {@link ShellProcess.readOutput} + * cursor. A stream is absent when its backend cannot expose offset reads. + */ +export interface ShellObservedStreams { + /** Offset reader over captured stdout, when the backend exposes one. */ + stdout?: SubprocessOutputReader + /** Offset reader over captured stderr, when the backend exposes one. */ + stderr?: SubprocessOutputReader +} /** * Sandbox facts for one run, present iff a sandboxing executor handled it. @@ -175,6 +187,13 @@ export interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. Absent when the + * backend cannot expose offset reads; observers then see no stream. + */ + observed?: ShellObservedStreams /** * Kill the process group. Returns false when it had already finished * (no-op); idempotent. diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index f3acda2aca..ae0daca26d 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-bash/README.md -README.md: 21749bf0a1cd3cdc46257fb2a02ba6b4ab1f5ee6 -README.zh.md: 860db1a4c347d1aefe04f70e14a65e4b511ff7f6 +README.md: acf7e68cca6b611adf71ed7bad690294f1bdb14d +README.zh.md: 9aeae12bce13f87246a7cdea8c5b41ec8bbd4bc5 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 21749bf0a1..acf7e68cca 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -34,7 +34,7 @@ Result text contains stdout, an optional `[stderr]` section, then applicable san The canonical success is `{ kind: 'foreground', ...ShellRunResult }` for a completed foreground process or `{ kind: 'background', jobId }` for a published task. The Native renderer preserves the text above, including exactly `started background job `; programmatic consumers use the typed fields without parsing those strings. Executor stream caps remain acquisition limits on `ShellRunResult` and carry their spill paths. -When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. +When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. When the optional `ctx.activities` registry is loaded, the committed background run is also mirrored into it best-effort: a `bash` activity correlated to the call and job pumps the handle's non-consuming `observed` readers at `activityPollMs` (default 150) so the Web client streams live output without touching the job's cursor; any observation failure is logged and swallowed. ## UI presentation diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index 860db1a4c3..9aeae12bce 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -34,7 +34,7 @@ 已完成前台进程的规范成功值为 `{ kind: 'foreground', ...ShellRunResult }`,已发布任务则为 `{ kind: 'background', jobId }`。Native renderer 保留上述文本,包括精确的 `started background job `;程序化消费方使用带类型字段,无需解析这些字符串。执行器的流上限仍是 `ShellRunResult` 的采集限制,并携带其 spill 路径。 -当 `run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.jobs.start()`,把调用方 agent 注册为持有者,并将返回的 `ShellProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。 +当 `run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.jobs.start()`,把调用方 agent 注册为持有者,并将返回的 `ShellProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。当可选的 `ctx.activities` 注册表已加载时,已提交的后台运行还会尽力镜像进去:一个与调用和 job 关联的 `bash` activity 以 `activityPollMs`(默认 150)的节奏泵取句柄的非消费 `observed` 读取器,让 Web 客户端在不动 job 游标的前提下实时看到输出;任何观察失败只记日志并吞掉。 ## UI 展示 diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 321e2822d2..557fbe37f2 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -32,42 +32,44 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^" } } diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index 37c4a762e3..ed4fd5bac6 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -23,8 +23,10 @@ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandb import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' -import type { ShellRunResult } from '@deepseek-ai/dsh-shell' +import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' +import type {} from '@deepseek-ai/dsh-activity' import { processOutcome } from './background.ts' +import { observeBackgroundActivity } from './observe.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' export const name = 'tool-bash' @@ -34,11 +36,18 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } /** Runtime configuration schema for the bash tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), + activityPollMs: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(150), }) /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ @@ -189,6 +198,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = { export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true + const activityPollMs = config.activityPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -362,12 +372,14 @@ export function apply(ctx: Context, config: Config = {}): void { throw error } // Task preflight finishes before the starter can spawn a process. + let started: ShellProcess | undefined const id = jobs.start({ kind: 'bash', label: args.command, ...exec.agent ? { owner: exec.agent } : {}, run: () => { const proc = ctx.shell.start(ctx.shell.resolve(request)) + started = proc return { cancel: () => void proc.kill(), done: proc.done.then(() => processOutcome(proc)), @@ -375,6 +387,16 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) + // The starter ran synchronously inside the committed start; mirror the + // spawned process into the optional observation registry. + if (started !== undefined) { + observeBackgroundActivity(ctx, started, { + kind: 'bash', + label: args.command, + ...exec.agent ? { owner: exec.agent } : {}, + correlation: { callId: exec.callId, jobId: id }, + }, activityPollMs, processOutcome) + } return { kind: 'background' as const, jobId: id } } const result = await ctx.shell.run(ctx.shell.resolve({ diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts new file mode 100644 index 0000000000..7b5179aa59 --- /dev/null +++ b/packages/shell/tool-bash/src/observe.ts @@ -0,0 +1,72 @@ +/** + * Best-effort process-observation tap for background bash runs: mirrors the + * spawned process's captured streams into `ctx.activities` so non-consuming + * observers (the Web client) see live output without touching the job's + * consuming `readOutput` cursor. Absent registry or absent backend offset + * readers degrade to no observation; the job path is unaffected either way. + * + * @module @deepseek-ai/dsh-tool-bash/observe + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ShellProcess } from '@deepseek-ai/dsh-shell' +import { pumpActivityOutput } from '@deepseek-ai/dsh-activity' +import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity' + +/** Identity of one observed background run. */ +export interface ObserveBackgroundSpec { + /** Process kind — the producing tool's registered {@link ActivityKind}. */ + kind: ActivityKind + /** One-line label (the command). */ + label: string + /** Owning live agent, when the call has one. */ + owner?: Agent + /** Links to the starting tool call and the registered job. */ + correlation: ActivityCorrelation +} + +/** + * Open a process beside a committed background job and pump the handle's + * non-consuming offset readers into it until the run settles, then record the + * mapped outcome. Observation is strictly best-effort: without a + * `ctx.activities` registry this is a no-op, without backend offset readers + * the process still carries status and settlement, and ANY observation + * failure — including a rejected `open()` — is logged and swallowed, because + * the background job is already committed and running. + * @param ctx - plugin context used for the optional registry and warning logs. + * @param proc - the started background process handle. + * @param spec - process identity and correlation for the registry row. + * @param pollMs - producer-configured pump cadence in milliseconds. + * @param outcome - maps the settled handle to the process outcome. + */ +export function observeBackgroundActivity( + ctx: Context, + proc: ShellProcess, + spec: ObserveBackgroundSpec, + pollMs: number, + outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string }, +): void { + try { + const processes = ctx.get('activities') + if (processes === undefined) return + const handle = processes.open({ + kind: spec.kind, + label: spec.label, + ...spec.owner !== undefined ? { owner: spec.owner } : {}, + correlation: spec.correlation, + }) + const sources: ActivityPumpSource[] = [] + const stdout = proc.observed?.stdout + if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) + const stderr = proc.observed?.stderr + if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) + void pumpActivityOutput(handle, sources, { pollMs, done: proc.done }) + .catch((error: unknown) => { + ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`) + }) + .then(() => { handle.end(outcome(proc)) }) + } catch (error: unknown) { + ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`) + } +} diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts new file mode 100644 index 0000000000..c0243ba5b9 --- /dev/null +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -0,0 +1,194 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import type { ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' + +const testToolSignal = new AbortController().signal +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-observe-spec-')) + +/** Job harness plus the observation registry, with a fast pump for tests. */ +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin(LocalActivityRegistry) + await ctx.plugin(LocalSubprocessRuntime) + ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + await ctx.plugin(ToolBash, { activityPollMs: 25 }) + return ctx +} + +let callCounter = 0 +function call(ctx: Context, args: Record) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`observe-call-${++callCounter}`), + name: 'bash', + arguments: args, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +async function until(read: () => T | undefined, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const value = read() + if (value !== undefined) return value + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error('condition not reached before timeout') +} + +function retainedText(ctx: Context, id: ActivitySnapshot['id']): string { + return ctx.activities.read(id, 0).chunks.map(chunk => chunk.text).join('') +} + +describe('background bash observation', () => { + it('mirrors a background run into ctx.activities with correlation, live output, and settlement', async () => { + const ctx = await setup() + const ack = await call(ctx, { + command: 'printf "line-1\\n"; sleep 0.4; printf "line-2\\n"', + description: 'test command', + run_in_background: true, + }) + expect(text(ack)).toContain('started background job') + const job = ctx.jobs.list()[0] + expect(job).toBeDefined() + + const snapshot = await until(() => + ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) + expect(snapshot.kind).toBe('bash') + expect(snapshot.label).toContain('printf') + expect(String(snapshot.correlation?.callId)).toMatch(/^observe-call-/) + + // Live output appears while the command is still running. + await until(() => retainedText(ctx, snapshot.id).includes('line-1') ? true : undefined) + expect(ctx.activities.get(snapshot.id).status).toBe('running') + + // Settlement carries the mapped bash outcome and the trailing output. + await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined) + expect(ctx.activities.get(snapshot.id).detail).toBe('exit code: 0') + expect(retainedText(ctx, snapshot.id)).toContain('line-2') + + // The model-facing consuming cursor still delivers everything: observation stole nothing. + const collected = await until(() => { + const read = ctx.jobs.read(job!.id) + return read.text.includes('line-1') ? read.text : undefined + }) + expect(collected).toContain('line-2') + }) + + it('a killed background job settles its mirrored process as killed', async () => { + const ctx = await setup() + await call(ctx, { command: 'sleep 60', description: 'test command', run_in_background: true }) + const job = ctx.jobs.list()[0] + const snapshot = await until(() => + ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) + ctx.jobs.kill(job!.id, undefined, 'test cleanup') + await until(() => ctx.activities.get(snapshot.id).status === 'killed' ? true : undefined) + expect(ctx.activities.get(snapshot.id).detail).toMatch(/signal|killed before exit/) + }) + + it('labels stderr chunks with their channel', async () => { + const ctx = await setup() + await call(ctx, { + command: 'echo out-line; echo err-line 1>&2', + description: 'test command', + run_in_background: true, + }) + const job = ctx.jobs.list()[0] + const snapshot = await until(() => + ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) + await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined) + const chunks = ctx.activities.read(snapshot.id, 0).chunks + expect(chunks.find(chunk => chunk.text.includes('out-line'))?.channel).toBe('stdout') + expect(chunks.find(chunk => chunk.text.includes('err-line'))?.channel).toBe('stderr') + }) + + it('a registry whose handle rejects appends degrades to a warning and still settles', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + const ended: unknown[] = [] + await ctx.plugin({ + name: 'appending-broken-activities-probe', + apply(child: Context) { + child.provide('activities', { + open: () => ({ + id: 'bash-1', + append() { throw new Error('append boom') }, + updateDetail() {}, + end(outcome: unknown) { ended.push(outcome) }, + }), + }) + }, + }) + await ctx.plugin(LocalSubprocessRuntime) + ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin(ToolBash, { activityPollMs: 25 }) + await call(ctx, { command: 'echo tolerated', description: 'test command', run_in_background: true }) + await until(() => ended.length > 0 ? true : undefined) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation pump')) + expect(ended[0]).toMatchObject({ status: 'completed' }) + }) + + it('a throwing observation registry never breaks the background call', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin({ + name: 'broken-activities-probe', + apply(child: Context) { + child.provide('activities', { open() { throw new Error('observation boom') } }) + }, + }) + await ctx.plugin(LocalSubprocessRuntime) + ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin(ToolBash, { activityPollMs: 25 }) + const ack = await call(ctx, { command: 'echo resilient', description: 'test command', run_in_background: true }) + expect(text(ack)).toContain('started background job') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation unavailable')) + // The job itself still completes and reads normally. + const job = ctx.jobs.list()[0] + const read = await until(() => { + const value = ctx.jobs.read(job!.id) + return value.text.includes('resilient') ? value : undefined + }) + expect(read.snapshot.status).toBe('completed') + }) +}) diff --git a/packages/shell/tool-bash/tsconfig.json b/packages/shell/tool-bash/tsconfig.json index 304d983a95..8aa66f93ff 100644 --- a/packages/shell/tool-bash/tsconfig.json +++ b/packages/shell/tool-bash/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../activity/activity" } ] } diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index c5dcb85005..0a88134fb3 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-pwsh/README.md -README.md: e862fcf0ca85d0ecb0a5fe6cff3ee3c7a8153716 -README.zh.md: 0a66b119ac2bf0386c0686e9e22ddfe1c3bb87b1 +README.md: 5ebc22e8607337c2698756891cce2b9ba9e06184 +README.zh.md: 2e5186383babadab9ea894b3cb6364ab8bc23e6b diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index e862fcf0ca..5ebc22e860 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -34,7 +34,7 @@ Result text contains stdout, an optional `[stderr]` section, then applicable tru The canonical success is `{ kind: 'foreground', ...ShellRunResult }` for a completed foreground process (with the executor's `sandbox` facts — `mode`/`denied`, optional `enforcement`/`runnerFailed` — projected when present) or `{ kind: 'background', jobId }` for a published task. The renderer preserves exactly `started background job ` for background acks; programmatic consumers use the typed fields without parsing the rendered text. -When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. +When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. When the optional `ctx.activities` registry is loaded, the committed background run is also mirrored into it best-effort as a `pwsh` activity pumped at `activityPollMs` (default 150), exactly like `dsh-tool-bash`. ## UI presentation diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 0a66b119ac..2e5186383b 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -34,7 +34,7 @@ 规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...ShellRunResult }`(存在时投影执行器的 `sandbox` 事实——`mode`/`denied`、可选的 `enforcement`/`runnerFailed`)或已发布任务的 `{ kind: 'background', jobId }`。渲染器对后台 ack 精确保留 `started background job `;编程消费者使用类型化字段而不解析渲染文本。 -当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.jobs.start()`,把调用 agent 注册为 owner,并将返回的 `ShellProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。 +当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.jobs.start()`,把调用 agent 注册为 owner,并将返回的 `ShellProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。当可选的 `ctx.activities` 注册表已加载时,已提交的后台运行还会尽力镜像为一个 `pwsh` activity,以 `activityPollMs`(默认 150)的节奏泵取,与 `dsh-tool-bash` 完全一致。 ## UI presentation diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 2294a955a3..3156508e39 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -32,39 +32,41 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^" } } diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index 603322e381..f3e63551e5 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -33,9 +33,11 @@ import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' -import type { ShellRunResult } from '@deepseek-ai/dsh-shell' +import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' import { parseExitStatus } from '@deepseek-ai/dsh-shell' +import type {} from '@deepseek-ai/dsh-activity' import { processOutcome } from './background.ts' +import { observeBackgroundActivity } from './observe.ts' import { renderPwshProcessRead, renderPwshResult } from './render.ts' import type { RenderablePwshResult } from './render.ts' @@ -45,19 +47,34 @@ declare module '@deepseek-ai/dsh-jobs' { } } +declare module '@deepseek-ai/dsh-activity' { + interface ActivityKindMap { + pwsh: 'pwsh' + } +} + export const name = 'tool-pwsh' export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] +/* jscpd:ignore-start -- the pwsh Config mirrors tool-bash's by design, like render/background. */ /** Configuration for the pwsh tool. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } /** Runtime configuration schema for the pwsh tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), + activityPollMs: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(150), }) +/* jscpd:ignore-end */ /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ interface PwshToolArgs { @@ -194,6 +211,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = { /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */ export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true + const activityPollMs = config.activityPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -378,12 +396,14 @@ export function apply(ctx: Context, config: Config = {}): void { throw error } // Task preflight finishes before the starter can spawn a process. + let started: ShellProcess | undefined const id = jobs.start({ kind: 'pwsh', label: args.command, ...exec.agent ? { owner: exec.agent } : {}, run: () => { const proc = ctx.shell.start(ctx.shell.resolve(request)) + started = proc return { cancel: () => void proc.kill(), done: proc.done.then(() => processOutcome(proc)), @@ -391,6 +411,16 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) + // The starter ran synchronously inside the committed start; mirror the + // spawned process into the optional observation registry. + if (started !== undefined) { + observeBackgroundActivity(ctx, started, { + kind: 'pwsh', + label: args.command, + ...exec.agent ? { owner: exec.agent } : {}, + correlation: { callId: exec.callId, jobId: id }, + }, activityPollMs, processOutcome) + } return { kind: 'background' as const, jobId: id } } const result = await ctx.shell.run(ctx.shell.resolve({ diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts new file mode 100644 index 0000000000..6d342dda8b --- /dev/null +++ b/packages/shell/tool-pwsh/src/observe.ts @@ -0,0 +1,74 @@ +/** + * Best-effort process-observation tap for background pwsh runs: mirrors the + * spawned process's captured streams into `ctx.activities` so non-consuming + * observers (the Web client) see live output without touching the job's + * consuming `readOutput` cursor. Absent registry or absent backend offset + * readers degrade to no observation; the job path is unaffected either way. + * + * @module @deepseek-ai/dsh-tool-pwsh/observe + */ + +/* jscpd:ignore-start -- mirrors tool-bash's observe tap by design, like render/background. */ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ShellProcess } from '@deepseek-ai/dsh-shell' +import { pumpActivityOutput } from '@deepseek-ai/dsh-activity' +import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity' + +/** Identity of one observed background run. */ +export interface ObserveBackgroundSpec { + /** Process kind — the producing tool's registered {@link ActivityKind}. */ + kind: ActivityKind + /** One-line label (the command). */ + label: string + /** Owning live agent, when the call has one. */ + owner?: Agent + /** Links to the starting tool call and the registered job. */ + correlation: ActivityCorrelation +} + +/** + * Open a process beside a committed background job and pump the handle's + * non-consuming offset readers into it until the run settles, then record the + * mapped outcome. Observation is strictly best-effort: without a + * `ctx.activities` registry this is a no-op, without backend offset readers + * the process still carries status and settlement, and ANY observation + * failure — including a rejected `open()` — is logged and swallowed, because + * the background job is already committed and running. + * @param ctx - plugin context used for the optional registry and warning logs. + * @param proc - the started background process handle. + * @param spec - process identity and correlation for the registry row. + * @param pollMs - producer-configured pump cadence in milliseconds. + * @param outcome - maps the settled handle to the process outcome. + */ +export function observeBackgroundActivity( + ctx: Context, + proc: ShellProcess, + spec: ObserveBackgroundSpec, + pollMs: number, + outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string }, +): void { + try { + const processes = ctx.get('activities') + if (processes === undefined) return + const handle = processes.open({ + kind: spec.kind, + label: spec.label, + ...spec.owner !== undefined ? { owner: spec.owner } : {}, + correlation: spec.correlation, + }) + const sources: ActivityPumpSource[] = [] + const stdout = proc.observed?.stdout + if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) + const stderr = proc.observed?.stderr + if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) + void pumpActivityOutput(handle, sources, { pollMs, done: proc.done }) + .catch((error: unknown) => { + ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`) + }) + .then(() => { handle.end(outcome(proc)) }) + } catch (error: unknown) { + ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`) + } +} +/* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/observe.spec.ts new file mode 100644 index 0000000000..25b5bbfbd4 --- /dev/null +++ b/packages/shell/tool-pwsh/tests/observe.spec.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import { ShellExecutor } from '@deepseek-ai/dsh-shell' +import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' + +const testToolSignal = new AbortController().signal + +/** ASCII-only scripted stream: readFrom offsets are byte-exact string indexes. */ +function scriptedReader(state: { text: string }) { + return { + readFrom(fromByte: number) { + return { text: state.text.slice(fromByte), nextOffset: state.text.length, lossy: false } + }, + } +} + +/** A running fake background handle with optional non-consuming observed streams. */ +function observableProcess(streams?: { stdout: { text: string }; stderr: { text: string } }) { + let resolveDone: () => void = () => {} + const done = new Promise((resolve) => { resolveDone = resolve }) + const proc: ShellProcess = { + status: 'running', + exitCode: null, + signal: null, + done, + readOutput: () => ({ delta: '', lossy: false }), + kill: () => false, + ...streams !== undefined + ? { observed: { stdout: scriptedReader(streams.stdout), stderr: scriptedReader(streams.stderr) } } + : {}, + } + return { + proc, + finish() { + proc.status = 'completed' + proc.exitCode = 0 + resolveDone() + }, + } +} + +class FakePwsh extends ShellExecutor { + backgroundHandler: (spec: ShellExecSpec) => ShellProcess = () => { throw new Error('unscripted start') } + + override resolve(request: ShellExecRequest): ShellExecSpec { + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + ...request.signal ? { signal: request.signal } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + override async run(): Promise { + throw new Error('foreground is not exercised here') + } + + override start(spec: ShellExecSpec): ShellProcess { + return this.backgroundHandler(spec) + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin(LocalActivityRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakePwsh) + await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) + return { ctx, pwsh: ctx.shell as FakePwsh } +} + +let callCounter = 0 +function call(ctx: Context, args: Record) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`pwsh-observe-${++callCounter}`), + name: 'pwsh', + arguments: args, + }) +} + +async function until(read: () => T | undefined, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const value = read() + if (value !== undefined) return value + await new Promise(resolve => setTimeout(resolve, 5)) + } + throw new Error('condition not reached before timeout') +} + +describe('background pwsh observation', () => { + it('mirrors observed streams with channels and settles with the mapped outcome', async () => { + const { ctx, pwsh } = await setup() + const stdout = { text: '' } + const stderr = { text: '' } + const scripted = observableProcess({ stdout, stderr }) + pwsh.backgroundHandler = () => scripted.proc + + await call(ctx, { command: 'Get-Progress', description: 'test command', run_in_background: true }) + const job = ctx.jobs.list()[0] + const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id)) + expect(row.kind).toBe('pwsh') + + stdout.text = 'progress-line\n' + stderr.text = 'warn-line\n' + await until(() => { + const chunks = ctx.activities.read(row.id, 0).chunks + return chunks.some(chunk => chunk.text.includes('progress-line')) + && chunks.some(chunk => chunk.text.includes('warn-line')) + ? true + : undefined + }) + const chunks = ctx.activities.read(row.id, 0).chunks + expect(chunks.find(chunk => chunk.text.includes('progress-line'))?.channel).toBe('stdout') + expect(chunks.find(chunk => chunk.text.includes('warn-line'))?.channel).toBe('stderr') + + scripted.finish() + await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined) + expect(ctx.activities.get(row.id).detail).toBe('exit code: 0') + }) + + it('a backend without observed readers still yields a status-only process', async () => { + const { ctx, pwsh } = await setup() + const scripted = observableProcess() + pwsh.backgroundHandler = () => scripted.proc + + await call(ctx, { command: 'Start-Job', description: 'test command', run_in_background: true }) + const job = ctx.jobs.list()[0] + const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id)) + expect(ctx.activities.read(row.id, 0).chunks).toEqual([]) + + scripted.finish() + await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined) + expect(ctx.activities.get(row.id).outputTotal).toBe(0) + }) +}) diff --git a/packages/shell/tool-pwsh/tsconfig.json b/packages/shell/tool-pwsh/tsconfig.json index 59a4d9478c..d38c8f031d 100644 --- a/packages/shell/tool-pwsh/tsconfig.json +++ b/packages/shell/tool-pwsh/tsconfig.json @@ -52,6 +52,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../activity/activity" } ] } diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index f87a12407f..65a8c97d7b 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/tool-workflow/README.md -README.md: 918fe5b9aff74b3b959107ec1eebfd7966afe6d1 -README.zh.md: 2546a0fc92ef39d71949cc0061dcf363ecc2e22d +README.md: 29e84c7386768b77c7b85ed8426f6f604a53fb44 +README.zh.md: 582828cf7c2d5b6c6e7dca6230d29fe744b578fe diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index 918fe5b9af..29e84c7386 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -14,7 +14,7 @@ Collection is synchronous (like [`dsh-tool-subagent`](../../subagent/tool-subage For a root transport execution (`exec.parent` absent), the tool also projects the run into the calling Agent's Session: run-start after `start()` returns, matching member starts and endings filtered by `run.id`, then run-end only after `run.result` is available and `dispose()` has reached quiescence. Nested transport calls execute normally but write no workflow record. The first failed Session append disables later recording for that run, emits one warning, and leaves either no record or a legal continuous prefix without changing the tool result or cleanup. -The browser-safe `@deepseek-ai/dsh-tool-workflow/types` subpath owns these four log-only event payloads and their `SessionEventMap` declaration. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append while accepting missing terminal suffixes. +The browser-safe `@deepseek-ai/dsh-tool-workflow/types` subpath owns these four log-only event payloads and their `SessionEventMap` declaration. Beside the durable record, each recorded top-level run is mirrored best-effort into the optional `ctx.activities` registry as a `workflow` activity: the otherwise-unconsumed `workflow/phase` and `workflow/log` events plus member lifecycle lines stream into it, so the Web client shows the run's live narration the session log deliberately does not carry per line; without the registry, or on any mirror failure, the run is unaffected. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append while accepting missing terminal suffixes. ## Render intent diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index 2546a0fc92..582828cf7c 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -14,7 +14,7 @@ 对于根 transport 执行(`exec.parent` 缺省),工具还会把运行投影到调用 Agent 的 Session:`start()` 返回后写 run-start,只记录 `run.id` 匹配的成员开始与结束,并且只在 `run.result` 已取得且 `dispose()` 完全停稳后写 run-end。嵌套 transport 调用照常执行,但不写工作流记录。任一次 Session append 首次失败后,本运行会停止后续记录并只告警一次,留下空记录或合法连续前缀,同时不改变工具结果和清理。 -浏览器安全的 `@deepseek-ai/dsh-tool-workflow/types` 子路径拥有这四类 log-only 事件 payload 及其 `SessionEventMap` 声明。包 invariant 会在冷加载和实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点和 run-end 后更新,同时允许缺失终态后缀的连续前缀。 +浏览器安全的 `@deepseek-ai/dsh-tool-workflow/types` 子路径拥有这四类 log-only 事件 payload 及其 `SessionEventMap` 声明。在持久记录之外,每个被记录的顶层运行还会尽力镜像进可选的 `ctx.activities` 注册表成为一个 `workflow` activity:原本无人消费的 `workflow/phase` 与 `workflow/log` 事件加上成员生命周期行会流入其中,Web 客户端因此能看到会话日志有意不逐行携带的实时叙述;没有该注册表或镜像任何失败时,运行不受影响。包 invariant 会在冷加载和实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点和 run-end 后更新,同时允许缺失终态后缀的连续前缀。 ## 渲染意图 diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 455d2ee54c..6cf4bebe3d 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -37,19 +37,23 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-workflow": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-workflow": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-activity-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -58,7 +62,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", - "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^" } } diff --git a/packages/workflow/tool-workflow/src/activity.ts b/packages/workflow/tool-workflow/src/activity.ts new file mode 100644 index 0000000000..36b251b894 --- /dev/null +++ b/packages/workflow/tool-workflow/src/activity.ts @@ -0,0 +1,119 @@ +/** + * Best-effort activity mirror for top-level workflow runs: opens one + * `workflow` activity per run and streams the engine's `workflow/phase`, + * `workflow/log`, and member lifecycle events into it as text lines, so the + * Web client shows live progress the session log deliberately does not carry + * per line. Without a `ctx.activities` registry every operation is a no-op, + * and ANY mirror failure is logged and swallowed — the run is unaffected. + * + * @module @deepseek-ai/dsh-tool-workflow/activity + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ActivityHandle } from '@deepseek-ai/dsh-activity' +import type { WorkflowRun, WorkflowRunId, WorkflowStopReason } from '@deepseek-ai/dsh-workflow' + +declare module '@deepseek-ai/dsh-activity' { + interface ActivityKindMap { + workflow: 'workflow' + } +} + +/** Activity taps for the three points the tool already tracks a run at. */ +export interface WorkflowActivityMirror { + /** + * Open the run's activity. Call once per recorded top-level run. + * @param run - the started run (id and meta name). + * @param owner - the calling agent, when the call has one. + */ + start(run: WorkflowRun, owner: Agent | undefined): void + /** + * End the run's activity with the mapped stop reason. + * @param runId - the settled run. + * @param stopReason - how the run stopped. + */ + finish(runId: WorkflowRunId, stopReason: WorkflowStopReason): void + /** + * Drop tracking for a run that ends without a settled reason. First-wins + * with {@link finish}: after a normal finish this is inert. + * @param runId - the abandoned run. + */ + abandon(runId: WorkflowRunId): void +} + +/** Map a run's stop reason onto the activity outcome vocabulary. */ +function outcomeOf(stopReason: WorkflowStopReason): { status: 'completed' | 'failed' | 'killed'; detail: string } { + switch (stopReason) { + case 'completed': return { status: 'completed', detail: 'completed' } + case 'cancelled': return { status: 'killed', detail: 'cancelled' } + case 'error': return { status: 'failed', detail: 'error' } + } +} + +/** + * Create the run-to-activity mirror and subscribe the engine's live progress + * events for the runs it tracks. + * @param ctx - plugin context used for the optional registry and warning logs. + * @returns the mirror taps the tool wires beside its durable recorder. + */ +export function createWorkflowActivityMirror(ctx: Context): WorkflowActivityMirror { + const active = new Map() + const contained = (operation: string, act: () => void): void => { + try { + act() + } catch (error: unknown) { + ctx.logger.warn(`workflow activity mirror ${operation} failed: ${String(error)}`) + } + } + + ctx.on('workflow/phase', (info, title) => { + const handle = active.get(info.id) + if (handle === undefined) return + contained('phase', () => { + handle.updateDetail(title) + handle.append(`▸ ${title}\n`) + }) + }) + ctx.on('workflow/log', (info, message) => { + const handle = active.get(info.id) + if (handle === undefined) return + contained('log', () => { handle.append(`${message}\n`) }) + }) + ctx.on('workflow/agent-start', (info, agent) => { + const handle = active.get(info.id) + if (handle === undefined) return + contained('agent-start', () => { handle.append(`agent #${agent.seq} ${agent.label} started\n`) }) + }) + ctx.on('workflow/agent-end', (info, agent) => { + const handle = active.get(info.id) + if (handle === undefined) return + contained('agent-end', () => { handle.append(`agent #${agent.seq} ${agent.outcome}\n`) }) + }) + + return { + start(run, owner) { + contained('open', () => { + const activities = ctx.get('activities') + if (activities === undefined) return + active.set(run.id, activities.open({ + kind: 'workflow', + label: run.meta.name, + ...owner !== undefined ? { owner } : {}, + })) + }) + }, + finish(runId, stopReason) { + const handle = active.get(runId) + active.delete(runId) + if (handle === undefined) return + contained('finish', () => { handle.end(outcomeOf(stopReason)) }) + }, + abandon(runId) { + const handle = active.get(runId) + active.delete(runId) + if (handle === undefined) return + contained('abandon', () => { handle.end({ status: 'killed', detail: 'run abandoned' }) }) + }, + } +} diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 446c70ce03..0da8c0ff83 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -25,6 +25,7 @@ import type { } from './types.ts' // Declaration merge only: makes ctx.systemPrompt visible for the section registration. import type {} from '@deepseek-ai/dsh-system-prompt' +import { createWorkflowActivityMirror } from './activity.ts' export const name = 'tool-workflow' export const inject = ['tools', 'workflowEngine', 'systemPrompt'] @@ -207,6 +208,7 @@ export function apply(ctx: Context, config: Config): void { // fields; the assertion records that resolution, not a hidden fallback. const { toolName, maxResultChars } = config as ResolvedConfig const recorder = createWorkflowRecorder(ctx) + const mirror = createWorkflowActivityMirror(ctx) // Usage policy ships with the tool (the master convention: tool guidance // lives in tool plugins as prompt sections, not in the deployment persona). ctx.systemPrompt.section({ @@ -291,7 +293,10 @@ export function apply(ctx: Context, config: Config): void { const recordsRun = exec.parent === undefined // The shipped worker-thread engine publishes member events from later // worker messages, after start() returns and this run record is active. - if (recordsRun) recorder.start(parent.session, run) + if (recordsRun) { + recorder.start(parent.session, run) + mirror.start(run, parent) + } // Bridge the tool's abort signal to the run: if the parent step is aborted while the // script is in flight, cancel the whole run. The signal also enters the engine directly, but @@ -323,9 +328,13 @@ export function apply(ctx: Context, config: Config): void { /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ if (result === undefined) throw new Error('workflow run settled without a result') recorder.finish(run.id, result.stopReason) + mirror.finish(run.id, result.stopReason) } } finally { - if (recordsRun) recorder.abandon(run.id) + if (recordsRun) { + recorder.abandon(run.id) + mirror.abandon(run.id) + } } } }, diff --git a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts new file mode 100644 index 0000000000..50788d453f --- /dev/null +++ b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import type { WorkflowRun, WorkflowRunId, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' +import { createWorkflowActivityMirror } from '../src/activity.ts' + +function runStub(id = 'run-1'): { run: WorkflowRun; info: WorkflowRunInfo } { + const info = { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRunInfo + return { + run: { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRun, + info, + } +} + +function retainedText(ctx: Context): string { + const row = ctx.activities.list()[0] + if (row === undefined) throw new Error('no mirrored activity') + return ctx.activities.read(row.id, 0).chunks.map(chunk => chunk.text).join('') +} + +describe('workflow activity mirror', () => { + it('opens one workflow activity per run and streams phase, log, and member lines', async () => { + const ctx = new Context() + await ctx.plugin(LocalActivityRegistry) + const mirror = createWorkflowActivityMirror(ctx) + const { run, info } = runStub() + mirror.start(run, undefined) + + const row = ctx.activities.list()[0] + expect(row?.kind).toBe('workflow') + expect(row?.label).toBe('docs-sweep') + + ctx.emit('workflow/phase', info, 'Scan') + ctx.emit('workflow/log', info, '3 files queued') + ctx.emit('workflow/agent-start', info, { seq: 1, label: 'reader', childId: 'c1' } as never) + ctx.emit('workflow/agent-end', info, { seq: 1, label: 'reader', childId: 'c1', outcome: 'completed' } as never) + expect(ctx.activities.get(row!.id).detail).toBe('Scan') + expect(retainedText(ctx)).toBe('▸ Scan\n3 files queued\nagent #1 reader started\nagent #1 completed\n') + + mirror.finish(run.id, 'completed') + const settled = ctx.activities.get(row!.id) + expect(settled.status).toBe('completed') + // The always-run abandon after a normal finish is inert. + mirror.abandon(run.id) + expect(ctx.activities.get(row!.id).status).toBe('completed') + }) + + it('maps cancelled and error stops, and ends an abandoned run as killed', async () => { + const ctx = new Context() + await ctx.plugin(LocalActivityRegistry) + const mirror = createWorkflowActivityMirror(ctx) + + const cancelled = runStub('run-c') + mirror.start(cancelled.run, undefined) + mirror.finish(cancelled.run.id, 'cancelled') + + const failed = runStub('run-e') + mirror.start(failed.run, undefined) + mirror.finish(failed.run.id, 'error') + + const orphaned = runStub('run-o') + mirror.start(orphaned.run, undefined) + mirror.abandon(orphaned.run.id) + + const byLabelOrder = ctx.activities.list() + expect(byLabelOrder.map(row => [row.status, row.detail])).toEqual([ + ['killed', 'cancelled'], + ['failed', 'error'], + ['killed', 'run abandoned'], + ]) + }) + + it('ignores events for untracked runs and stays inert without the registry', async () => { + const bare = new Context() + const inert = createWorkflowActivityMirror(bare) + const { run, info } = runStub() + // No registry: every tap is a no-op rather than a throw. + inert.start(run, undefined) + bare.emit('workflow/log', info, 'dropped') + inert.finish(run.id, 'completed') + inert.abandon(run.id) + + const ctx = new Context() + await ctx.plugin(LocalActivityRegistry) + createWorkflowActivityMirror(ctx) + // Events for a run the mirror never opened change nothing. + ctx.emit('workflow/phase', info, 'ghost') + expect(ctx.activities.list()).toEqual([]) + }) + + it('contains a throwing registry so the run path never breaks', async () => { + const ctx = new Context() + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin({ + name: 'broken-activities-probe', + apply(child: Context) { + child.provide('activities', { open() { throw new Error('mirror boom') } }) + }, + }) + const mirror = createWorkflowActivityMirror(ctx) + const { run } = runStub() + expect(() => { mirror.start(run, undefined) }).not.toThrow() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('workflow activity mirror open failed')) + }) +}) diff --git a/packages/workflow/tool-workflow/tsconfig.json b/packages/workflow/tool-workflow/tsconfig.json index bfd38e61d4..a4cde5cbef 100644 --- a/packages/workflow/tool-workflow/tsconfig.json +++ b/packages/workflow/tool-workflow/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../activity/activity" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6878a775b..32f9a464b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -886,6 +886,110 @@ importers: specifier: workspace:^ version: link:../../interaction/user-approval + packages/activity/activity: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + + packages/activity/activity-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../activity + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + + packages/api/activity-controller: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../activity/activity + '@deepseek-ai/dsh-activity-local': + specifier: workspace:^ + version: link:../../activity/activity-local + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../gateway + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + packages/api/gateway: dependencies: '@deepseek-ai/dsh-typert-protocol': @@ -938,6 +1042,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-api-activity-controller': + specifier: workspace:^ + version: link:../activity-controller '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway @@ -1240,6 +1347,9 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer + '@deepseek-ai/dsh-activity-local': + specifier: workspace:^ + version: link:../../activity/activity-local '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1607,6 +1717,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-api-activity-controller': + specifier: workspace:^ + version: link:../../api/activity-controller '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -1631,6 +1744,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules + '@deepseek-ai/dsh-client-ui-activity': + specifier: workspace:^ + version: link:../../client/ui-activity '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../client/ui-agent-preset @@ -1976,6 +2092,45 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/client/ui-activity: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-activity-controller': + specifier: workspace:^ + version: link:../../api/activity-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-agent-preset: devDependencies: '@deepseek-ai/cordis': @@ -7701,6 +7856,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7820,6 +7978,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -9538,6 +9699,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../activity/activity + '@deepseek-ai/dsh-activity-local': + specifier: workspace:^ + version: link:../../activity/activity-local '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -9690,6 +9857,9 @@ importers: '@deepseek-ai/dsh-acp': specifier: workspace:^ version: link:../../packages/acp/acp + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../packages/activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index d5684867e6..3becddb8c7 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -5,6 +5,7 @@ "private": true, "type": "module", "dependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-group": "workspace:^", "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -12,15 +13,17 @@ "@deepseek-ai/cosmokit": "workspace:^", "@deepseek-ai/dsh": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", + "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-atomic-write": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", @@ -32,44 +35,43 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-hooks-claude-code": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", - "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-persona": "workspace:^", - "@deepseek-ai/dsh-pwsh-local": "workspace:^", - "@deepseek-ai/dsh-terminal": "workspace:^", - "@deepseek-ai/dsh-terminal-bash": "workspace:^", - "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", + "@deepseek-ai/dsh-permission-presets": "workspace:^", + "@deepseek-ai/dsh-persona": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", + "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", @@ -80,6 +82,8 @@ "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", @@ -91,31 +95,30 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", - "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-http": "workspace:^", @@ -124,8 +127,6 @@ "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/dsh-agent-instructions": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 05a4c8947c..095c33af7c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -105,6 +105,8 @@ export const SERVICE_PAGE: Record = { subprocess: 'subprocess.md', systemPrompt: 'system-prompt.md', jobs: 'jobs.md', + activities: 'activity.md', + activityController: 'activity.md', sessionTelemetry: 'session-telemetry.md', agentTeams: 'agent-team.md', tokenMeter: 'token-meter.md', @@ -168,6 +170,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API', workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API', + activityFeed: 'client-side interface-typed browser service — packages/api/activity-controller/README.md owns the API', } /** @@ -477,6 +480,16 @@ export const LINK_MAP: Readonly> = { PromptSection: 'system-prompt.md', SystemPrompt: 'system-prompt.md', ToolProviderResult: 'system-prompt.md', + ActivitiesChangedListener: 'activity.md', + ActivityHandle: 'activity.md', + ActivityId: 'activity.md', + ActivityOpen: 'activity.md', + ActivityOutputListener: 'activity.md', + ActivityRead: 'activity.md', + ActivitySnapshot: 'activity.md', + ActivityControlFrame: 'activity.md', + ActivityObserveFrame: 'activity.md', + ActivityObserveRequest: 'activity.md', JobDoneListener: 'jobs.md', JobId: 'jobs.md', JobRead: 'jobs.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index a47f94b943..83983f294f 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -164,6 +164,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace.', }, + { + key: 'activityController', + pkg: 'api-activity-controller', + title: 'Host Activity Remote controller', + mode: 'core', + note: 'Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace.', + }, { key: 'invariants', pkg: 'invariants', @@ -537,6 +544,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-bash', 'tool-terminal', 'tool-subagent', 'tool-jobs'], note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.', }, + { + key: 'activities', + pkg: 'activity', + title: 'Live activity observation registry', + mode: 'seam', + implementations: ['activity-local'], + consumers: ['tool-bash', 'tool-pwsh', 'tool-workflow', 'activity-controller'], + note: 'Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched.', + }, { key: 'web', pkg: 'web', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index f83b621965..48bc458bfa 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -45,6 +45,10 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, + 'packages/activity/activity': { kind: 'none', reason: 'Pure observation seam for humans; activity output becomes model-visible only through producers\' existing tool results and jobs reads.' }, + 'packages/activity/activity-local': { kind: 'none', reason: 'In-memory observation provider; producers own every model-visible surface.' }, + 'packages/api/activity-controller': { kind: 'none', reason: 'Browser/Host observation transport; registers no prompt, tool, or session event.' }, + 'packages/client/ui-activity': { kind: 'none', reason: 'Browser-side presentation of host-observed output; touches no prompt, message, schema, stream, or tool result.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, 'packages/shell/shell-env': { kind: 'indirect', reason: 'The env service exposes managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' }, 'packages/shell/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 8a8bc37baa..11ae967059 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"], "@deepseek-ai/dsh-api-gateway/invariant": ["./packages/api/gateway/src/invariant.ts"], "@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"], + "@deepseek-ai/dsh-api-activity-controller": ["./packages/api/activity-controller/src/index.ts"], "@deepseek-ai/dsh-api-session-controller": ["./packages/api/session-controller/src/index.ts"], "@deepseek-ai/dsh-api-session-controller/client": ["./packages/api/session-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-session-controller/invariant": ["./packages/api/session-controller/src/invariant.ts"], @@ -56,6 +57,7 @@ "@deepseek-ai/dsh-api-workspace-controller/client": ["./packages/api/workspace-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-workspace-controller/invariant": ["./packages/api/workspace-controller/src/invariant.ts"], "@deepseek-ai/dsh-api-workspace-controller/types": ["./packages/api/workspace-controller/src/types.ts"], + "@deepseek-ai/dsh-api-activity-controller/types": ["./packages/api/activity-controller/src/types.ts"], "@deepseek-ai/dsh-typert-protocol": ["./packages/typert/protocol/src/index.ts"], "@deepseek-ai/dsh-typert-protocol/types": ["./packages/typert/protocol/src/types.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], @@ -95,6 +97,8 @@ "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"], + "@deepseek-ai/dsh-activity/brand": ["./packages/activity/activity/src/brand.ts"], + "@deepseek-ai/dsh-activity/invariant": ["./packages/activity/activity/src/invariant.ts"], "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"], "@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"], "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"], @@ -245,6 +249,7 @@ "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], "@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"], + "@deepseek-ai/dsh-client-ui-activity": ["./packages/client/ui-activity/src"], "@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"], "@deepseek-ai/dsh-client-ui-user-questions": ["./packages/client/ui-user-questions/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], @@ -288,6 +293,7 @@ "./packages/preset/*/src", "./packages/subagent/*/src", "./packages/jobs/*/src", + "./packages/activity/*/src", "./packages/experimental/*/src", "./packages/workflow/*/src", "./packages/webhook/*/src", diff --git a/tsconfig.client.json b/tsconfig.client.json index 291b813300..41f6aea41a 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -56,6 +56,7 @@ { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, + { "path": "./packages/api/activity-controller/tsconfig.client.json" }, { "path": "./packages/api/workspace-controller/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/extensions/cordis-client-runner" }, @@ -77,6 +78,7 @@ { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-jobs" }, + { "path": "./packages/client/ui-activity" }, { "path": "./packages/client/ui-directory-picker-browse" }, { "path": "./packages/client/ui-directory-picker-native" }, { "path": "./packages/client/ui-goal" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 0bcbe50df3..d8bcd91c08 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -77,6 +77,7 @@ "apps/web/tests/subagent-interrupt-ui.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", "apps/web/tests/background-job-list.e2e.ts", + "apps/web/tests/live-activity-stream.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", "apps/web/tests/turn-tail-actions.e2e.ts", @@ -146,6 +147,7 @@ { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/api/session-controller/tsconfig.host.json" }, + { "path": "./packages/api/activity-controller/tsconfig.host.json" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session/session-persistence" }, @@ -288,6 +290,8 @@ { "path": "./packages/jobs/jobs" }, { "path": "./packages/jobs/jobs-local" }, { "path": "./packages/jobs/tool-jobs" }, + { "path": "./packages/activity/activity" }, + { "path": "./packages/activity/activity-local" }, { "path": "./packages/experimental/agent-team" }, { "path": "./packages/experimental/tool-agent-team" }, { "path": "./packages/workflow/workflow" }, From ddd8baa93a7e6c7d3b61ab3d94ab3438f5e42f17 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 23:07:17 +0800 Subject: [PATCH 003/141] fix(activity): mark evicted heads on fresh observation and fit the popover to the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser acceptance against a live scaffold surfaced two defects: - A fresh observation anchors at the registry's earliest retained byte, so the client's gap predicate (anchor behind earliest) never fired for it: a viewer who watched eviction live saw the gap notice, while a reload of the same activity silently hid that the head was discarded. A fresh view (no accumulated text) anchored past offset zero now carries the gap mark; resumes keep their recorded state. - The trigger-anchored popover (left: 0, width 440px) overflowed the right viewport edge on narrow windows — max-width alone cannot see the anchor offset. The open popover now measures its anchor and shifts left to keep the Menu primitive's 12px viewport margin, re-fitting on window resize and falling back to the pure CSS anchor when layout reports no width (jsdom). JobListAction shares the latent overflow; tracked separately. --- .../activity-controller/src/client/model.ts | 7 +++- .../tests/client-model.client.spec.ts | 15 ++++++++ .../src/client/ActivityListAction.tsx | 37 ++++++++++++++++++- .../activity-list-action.client.spec.tsx | 37 +++++++++++++++++++ 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/packages/api/activity-controller/src/client/model.ts b/packages/api/activity-controller/src/client/model.ts index 96fb7cca5a..75354f1255 100644 --- a/packages/api/activity-controller/src/client/model.ts +++ b/packages/api/activity-controller/src/client/model.ts @@ -128,10 +128,15 @@ export class ClientActivityModel { */ observeOpened(id: ActivityId, frame: Extract): void { const existing = this.observedStates.get(String(id)) + // A fresh view anchored past offset zero starts after an evicted head + // (fresh observations anchor at the registry's earliest retained byte), so + // it owes the same gap mark a live observer earned from lossy reads. A + // resume that already accumulated text keeps its recorded gap state. + const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 const view: ObservedActivity = { activityId: id, text: existing?.view.text ?? '', - gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest, + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, status: frame.status, ...frame.detail !== undefined ? { detail: frame.detail } : {}, streaming: true, diff --git a/packages/api/activity-controller/tests/client-model.client.spec.ts b/packages/api/activity-controller/tests/client-model.client.spec.ts index 59ec9067c5..31f5d18c33 100644 --- a/packages/api/activity-controller/tests/client-model.client.spec.ts +++ b/packages/api/activity-controller/tests/client-model.client.spec.ts @@ -106,6 +106,21 @@ describe('ClientActivityModel observation', () => { expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) }) + it('marks a fresh observation anchored past the evicted head', () => { + // Fresh observations anchor at the registry's earliest retained byte, so + // from === earliest > 0 means the head was already discarded. + const fresh = new ClientActivityModel() + opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 }) + expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + // A retry that accumulated no text yet earns the mark the same way. + const retried = new ClientActivityModel() + opened(retried) + expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false) + opened(retried, { from: 6, earliest: 6, total: 6 }) + expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + it('bounds the render tail without splitting a surrogate pair', () => { const model = new ClientActivityModel() opened(model) diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-activity/src/client/ActivityListAction.tsx index 698815e171..1f868988b0 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.tsx +++ b/packages/client/ui-activity/src/client/ActivityListAction.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import type { ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, } from '@deepseek-ai/dsh-api-activity-controller/client' @@ -36,6 +36,9 @@ export type ActivityListActionProps = /** Stable empty list so a session with no activities keeps one array identity. */ const NO_ROWS: readonly ActivityRow[] = [] +/** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */ +const VIEWPORT_MARGIN = 12 + /** Height cap for one live output panel inside the popover. */ const PANEL_MAX_LINES = 16 @@ -170,12 +173,42 @@ export function ActivityListAction({ sessionId, useActivity, observe, t }: Activ const [expandedId, setExpandedId] = useState(undefined) const rootRef = useRef(null) const triggerRef = useRef(null) + const menuRef = useRef(null) + // Horizontal shift applied to the trigger-anchored popover so it stays + // inside the viewport (the stylesheet alone cannot see the anchor offset). + const [menuShift, setMenuShift] = useState(0) const rows = useMemo(() => ordered([...owned, ...unowned]), [owned, unowned]) const liveCount = useMemo(() => rows.filter(isLive).length, [rows]) useDismissOnOutsidePointer(rootRef, open, setOpen) + // Fit the open popover to the viewport: shift left when the anchored width + // would cross the right edge, never past the left margin. + useLayoutEffect(() => { + if (!open) { + setMenuShift(0) + return + } + const fit = (): void => { + const root = rootRef.current + const menu = menuRef.current + /* v8 ignore next -- both refs are attached while the open popover renders. */ + if (root === null || menu === null) return + const width = menu.offsetWidth + // Unlaid-out nodes (and jsdom) measure 0: keep the pure CSS anchor. + if (width === 0) return + const anchorLeft = root.getBoundingClientRect().left + setMenuShift(Math.max( + VIEWPORT_MARGIN - anchorLeft, + Math.min(0, window.innerWidth - VIEWPORT_MARGIN - width - anchorLeft), + )) + } + fit() + window.addEventListener('resize', fit) + return () => { window.removeEventListener('resize', fit) } + }, [open]) + // Observation follows visibility: the stream opens when a panel expands and // closes when it collapses, unmounts, or the popover closes. const activeId = open ? expandedId : undefined @@ -229,7 +262,7 @@ export function ActivityListAction({ sessionId, useActivity, observe, t }: Activ {/* jscpd:ignore-end */} {open ? ( -
      +
        {rows.map(row => ( { expect(screen.getAllByText(zh['status.completed']).length).toBeGreaterThan(0) }) + it('shifts an overflowing popover back inside the viewport and follows resizes', () => { + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440) + vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 344 } as DOMRect) + const originalWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) + try { + render() + openList() + const menu = screen.getByRole('list', { name: zh['list.aria'] }) + // 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin. + expect(menu.style.left).toBe('-96px') + + window.innerWidth = 900 + fireEvent(window, new Event('resize')) + // 900 - 12 - 440 - 344 = 104 > 0: the anchored position fits again. + expect(menu.style.left).toBe('0px') + } finally { + Object.defineProperty(window, 'innerWidth', { value: originalWidth, configurable: true, writable: true }) + } + }) + + it('never crosses the left viewport margin for an oversized popover', () => { + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800) + vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 4 } as DOMRect) + const originalWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) + try { + render() + openList() + const menu = screen.getByRole('list', { name: zh['list.aria'] }) + // max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin. + expect(menu.style.left).toBe('8px') + } finally { + Object.defineProperty(window, 'innerWidth', { value: originalWidth, configurable: true, writable: true }) + } + }) + it('ignores other keys while open and closes once the roster empties', () => { const settledPair = [ row({ id: 'bash-1' as ActivityId, status: 'completed', finishedAt: 2 }), From 61aadb3abf337875c43f95152a17c7ee9dc8d3ec Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 11:58:43 +0800 Subject: [PATCH 004/141] feat(activity): one session-header task list joining jobs with their activities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser acceptance of the activity panel surfaced the product problem: a background bash registers in both planes, so the header grew two near- identical popovers — "background jobs" with lifecycle and duration, "activities" with live output — listing the same work twice. The service planes stay orthogonal (jobs: model-visible control with a consuming read cursor; activities: transient non-consuming observation). The join happens per row in the UI projection instead: - dsh-client-ui-activity renders the single merged "tasks" list. Each jobsBySession row joins the activity carrying its correlation.jobId — the job supplies identity, lifecycle (stopping exists only there), duration, and the model-visible detail; the activity supplies the expandable live-output panel. Standalone activities (workflow runs) keep their own rows; without the activity registry, job rows still render and the control degrades to the old job list exactly. - dsh-client-ui-jobs is deleted (pre-release, no shim); the merged entry takes its slot order. The job locale namespace folds into activity. - User-facing copy says "task" (任务): N tasks running / N 个任务进行中. - Hierarchy: live rows first as two lines (label over kind badge and status, ticking duration), a Finished divider when both sections exist, settled rows compacted to one de-emphasized line, and the output panel hangs behind a spine under its row. Deliberately not joined: subagent rows stay bare (the subagent panel is their surface) and foreground commands stay in tool cards. Both web e2e scenarios now assert one Tasks list; the background-job scenario's row gained a live-output panel, proving the join end to end. Agent Note: 2026-08-25-unified-task-list. --- ...08-08-web-background-job-display.i18n.yaml | 4 +- .../2026-08-08-web-background-job-display.md | 2 +- ...026-08-08-web-background-job-display.zh.md | 2 +- .../2026-08-25-unified-task-list.i18n.yaml | 6 + .../feature/2026-08-25-unified-task-list.md | 31 ++ .../2026-08-25-unified-task-list.zh.md | 31 ++ apps/web/tests/background-job-list.e2e.ts | 6 +- apps/web/tests/live-activity-stream.e2e.ts | 4 +- .../background-job-list/running.expected.md | 7 +- .../background-job-list/settled.expected.md | 7 +- .../live-activity-stream/settled.expected.md | 4 +- .../streaming.expected.md | 4 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 - docs/config-catalog.zh.md | 1 - docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 57 +++- docs/module-graph.zh.md | 57 +++- packages/bundle/web-app/cordis.patch.yml | 3 - packages/bundle/web-app/package.json | 1 - packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- packages/client/ui-activity/README.i18n.yaml | 4 +- packages/client/ui-activity/README.md | 9 +- packages/client/ui-activity/README.zh.md | 9 +- packages/client/ui-activity/package.json | 4 +- .../src/client/ActivityListAction.module.css | 61 +++- .../src/client/ActivityListAction.tsx | 310 ++++++++++++++---- .../client/ui-activity/src/client/index.ts | 18 +- .../client/ui-activity/src/client/locales.ts | 34 +- .../activity-list-action.client.spec.tsx | 104 +++++- .../tests/browser-plugin.client.spec.ts | 3 +- packages/client/ui-activity/tsconfig.json | 1 + packages/client/ui-jobs/README.i18n.yaml | 6 - packages/client/ui-jobs/README.md | 24 -- packages/client/ui-jobs/README.zh.md | 24 -- packages/client/ui-jobs/package.json | 76 ----- .../src/client/JobListAction.module.css | 125 ------- .../ui-jobs/src/client/JobListAction.tsx | 183 ----------- packages/client/ui-jobs/src/client/index.ts | 42 --- packages/client/ui-jobs/src/client/locales.ts | 45 --- packages/client/ui-jobs/src/css-modules.d.ts | 6 - packages/client/ui-jobs/src/index.ts | 9 - packages/client/ui-jobs/src/invariant.ts | 32 -- .../tests/browser-plugin.client.spec.ts | 101 ------ .../tests/job-list-action.client.spec.tsx | 241 -------------- packages/client/ui-jobs/tsconfig.json | 39 --- packages/client/ui-jobs/tsdown.config.ts | 3 - .../src/client/slot-catalog.ts | 1 - pnpm-lock.yaml | 45 +-- .../verify-package-readme-model-experience.ts | 1 - tsconfig.base.json | 1 - tsconfig.client.json | 1 - 54 files changed, 639 insertions(+), 1167 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-25-unified-task-list.md create mode 100644 .agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md delete mode 100644 packages/client/ui-jobs/README.i18n.yaml delete mode 100644 packages/client/ui-jobs/README.md delete mode 100644 packages/client/ui-jobs/README.zh.md delete mode 100644 packages/client/ui-jobs/package.json delete mode 100644 packages/client/ui-jobs/src/client/JobListAction.module.css delete mode 100644 packages/client/ui-jobs/src/client/JobListAction.tsx delete mode 100644 packages/client/ui-jobs/src/client/index.ts delete mode 100644 packages/client/ui-jobs/src/client/locales.ts delete mode 100644 packages/client/ui-jobs/src/css-modules.d.ts delete mode 100644 packages/client/ui-jobs/src/index.ts delete mode 100644 packages/client/ui-jobs/src/invariant.ts delete mode 100644 packages/client/ui-jobs/tests/browser-plugin.client.spec.ts delete mode 100644 packages/client/ui-jobs/tests/job-list-action.client.spec.tsx delete mode 100644 packages/client/ui-jobs/tsconfig.json delete mode 100644 packages/client/ui-jobs/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 1ee7b9dfc3..9b64d73885 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 8da6c2fd914bf07cfa7d3545cff1e42552c69d27 -2026-08-08-web-background-job-display.zh.md: 0e05ef9d2fcd8193c661f471b5f7b9a84891f98a +2026-08-08-web-background-job-display.md: 23074b5d38cdab8c45925c75a7d0b851f7be2ece +2026-08-08-web-background-job-display.zh.md: 06645267ee8afadf284b7654b0cd4a672fe7e4b4 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 8da6c2fd91..23074b5d38 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -87,7 +87,7 @@ Two replacement points keep it honest. Each control-stream generation clears the ### The header action -[`@deepseek-ai/dsh-client-ui-jobs`](../../../../packages/client/ui-jobs/README.md) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. +`@deepseek-ai/dsh-client-ui-jobs` (since merged into `dsh-client-ui-activity`) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. A running one-shot background subagent therefore appears both there and in the subagent catalog. The two answer different questions — the catalog navigates into the child's transcript, this list is the only handle a cancellation can ever attach to — and suppressing `kind: 'subagent'` here would leave the cancellation phase with no entry point for exactly those tasks. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 0e05ef9d2f..06645267ee 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -87,7 +87,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void ### header 入口 -[`@deepseek-ai/dsh-client-ui-jobs`](../../../../packages/client/ui-jobs/README.zh.md) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 +`@deepseek-ai/dsh-client-ui-jobs`(后并入 `dsh-client-ui-activity`) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 `kind: 'subagent'` 会让中断那一期恰好对这批任务没有入口。 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml new file mode 100644 index 0000000000..031f01691a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-unified-task-list.md +2026-08-25-unified-task-list.md: 9ce3e6d2b066a94ee738880f69b0ddf718a10b02 +2026-08-25-unified-task-list.zh.md: ab224fd082fb8d6408449716e68e6df6c648a281 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md new file mode 100644 index 0000000000..9ce3e6d2b0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md @@ -0,0 +1,31 @@ +# Agent Note: One session-header task list — jobs joined with activities in the UI projection only + +Status: implemented + +English | [中文](2026-08-25-unified-task-list.zh.md) + +## Problem + +The [activity observation seam](2026-08-24-activity-observation-seam.md) shipped its Web surface as a second session-header button beside the [background-job list](2026-08-08-web-background-job-display.md). A background bash run registers in both planes — `ctx.jobs` for control and `ctx.activities` for observation — so the same work rendered twice: once in "background jobs" with lifecycle and duration, once in "activities" with expandable live output. Two near-identical popovers with different names for overlapping row sets read as a product accident, and the flat activity rows (live and settled interleaved, single-line, no duration) had no visual hierarchy. + +## Decision + +The service planes stay orthogonal; the join happens once, per row, in the UI projection. + +- **`ctx.jobs` and `ctx.activities` remain separate services.** Jobs are the control plane: model-visible ids, a consuming `readOutput` cursor the model drains, `stopping`, `kill`, and the `reported` completion-notice contract. Activities are the transient observation plane: non-consuming absolute offsets, invisible to the model, gone on restart. Merging the services would force one registry to carry both a consuming and a non-consuming cursor and would blur the "model-visible ⟺ logged" line for a plane designed to stay off the session log. +- **`dsh-client-ui-activity` renders the single merged list; `dsh-client-ui-jobs` is deleted** (pre-release, no compatibility shim), and the merged entry takes the job list's slot order. Each `jobsBySession` row is joined with the activity carrying its `correlation.jobId`: the job supplies identity, lifecycle (`stopping` exists only there), duration, and the model-visible `detail`; the activity supplies the expandable output panel. An activity without a job (a workflow run) keeps its own row; an activity whose job is not in the projection is not dropped. Without the activity registry, job rows still render — they just offer no panel, so the merged control degrades to exactly the old job list. +- **User-facing vocabulary is "task" (任务)** — `N tasks running` / `N 个任务进行中` — because every row is running work to the user, while "background job" excludes workflow runs and "activity" names the internal plane, not the user concept. The dictionary namespace and package names stay `activity`: internal names follow the naming ledger, not display copy. +- **Hierarchy**: live rows first (label over a kind-badge-and-status second line, ticking duration), then a "Finished" divider only when both sections exist, then settled rows compacted to one de-emphasized line. Rows without an observable activity render as static rows with no expansion affordance. + +Deliberately not joined: subagent delegation rows stay bare job rows (the subagent panel is their surface), and foreground commands stay in their tool cards — neither gains an activity producer. + +## Alternatives rejected + +- **Merge the services** — rejected for the cursor-semantics and model-visibility reasons above. +- **Keep the job list and bolt output onto it** — leaves workflow runs (activities without jobs) homeless, forcing a second list to survive anyway. + +## Consequences + +- The two web e2e scenarios (`background-job-list`, `live-activity-stream`) now assert one "Tasks" list; the job-list scenario's row gained a live-output panel, which is the join working end to end. +- A human kill control on live rows is still blocked on the jobs `reported` contract question recorded in the job-display note. +- The `job` locale namespace is gone with its package; `activity` owns the merged copy including the duration vocabulary. diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md new file mode 100644 index 0000000000..ab224fd082 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md @@ -0,0 +1,31 @@ +# Agent Note:会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并 + +状态:implemented + +[English](2026-08-25-unified-task-list.md) | 中文 + +## 问题 + +[activity 观察面](2026-08-24-activity-observation-seam.zh.md)的 Web 呈现落地为[后台任务列表](2026-08-08-web-background-job-display.zh.md)旁边的第二个会话头部按钮。一次后台 bash 会同时登记在两个面里——`ctx.jobs` 负责控制、`ctx.activities` 负责观察——于是同一份工作渲染了两次:一次在"后台任务"里带生命周期与时长,一次在"活动"里带可展开的实时输出。两个行集合高度重叠、名字却不同的近似弹层,读起来像产品事故;而活动列表的扁平行(live 与 settled 混排、单行、无时长)也缺乏视觉层次。 + +## 决策 + +服务面保持正交;合并只在 UI 投影层逐行发生一次。 + +- **`ctx.jobs` 与 `ctx.activities` 仍是两个服务。** jobs 是控制面:模型可见的 id、模型消耗式的 `readOutput` 游标、`stopping`、`kill` 与 `reported` 完成通知契约。activity 是瞬态观察面:非消耗式绝对偏移、模型不可见、重启即无。合并服务会迫使一个注册表同时携带消耗式与非消耗式两种游标,并模糊这个刻意不进 session log 的面与"模型可见 ⟺ 已记录"不变量的边界。 +- **`dsh-client-ui-activity` 渲染唯一的合并列表;`dsh-client-ui-jobs` 删除**(预发布阶段,不留兼容层),合并入口占用原 job 列表的 slot 顺位。每个 `jobsBySession` 行与携带其 `correlation.jobId` 的 activity 逐行 join:job 提供身份、生命周期(`stopping` 只存在于此)、时长与模型可见 `detail`;activity 提供可展开的输出面板。没有 job 的 activity(workflow 运行)保留自己的行;job 不在投影里的 activity 也不会被丢弃。没有 activity 注册表时 job 行照常渲染——只是没有面板,合并控件退化为与旧 job 列表完全等价。 +- **用户可见词汇统一为"任务"(task)**——`N 个任务进行中` / `N tasks running`——因为对用户而言每一行都是在跑的工作,而"后台任务"排除了 workflow 运行、"活动"命名的是内部面而非用户概念。词典命名空间与包名保持 `activity`:内部名遵循命名台账,不跟随展示文案。 +- **层次**:进行中的行在前(命令为主行、kind 徽章加状态为副行、时长每秒跳动),两段都存在时插入"已结束"分节线,已结束的行折为降权单行。没有可观察 activity 的行渲染为无展开交互的静态行。 + +刻意不合并的:subagent 委托行保持裸 job 行(subagent 面板是它的呈现面),前台命令留在工具卡片里——两者都不接活动生产者。 + +## 已否决的替代方案 + +- **合并服务层**——因上述游标语义与模型可见性理由否决。 +- **保留 job 列表、把输出挂上去**——workflow 运行(无 job 的 activity)无处安放,第二个列表终究得活下来。 + +## 后果 + +- 两个 web e2e 场景(`background-job-list`、`live-activity-stream`)现在断言同一个"任务"列表;job 场景的行获得了实时输出面板,这正是 join 端到端生效的证据。 +- live 行上的人工 kill 控件仍被 job-display note 记录的 jobs `reported` 契约问题阻塞。 +- `job` 词典命名空间随包一起消失;`activity` 拥有合并后的全部文案,包括时长词汇。 diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index b0fdc62ddb..0e1061ba09 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { it('shows a running background job in the session header without a refresh', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running')) // Polling for zero would pass at t=0 before delivery and prove nothing. - const trigger = page.getByRole('button', { name: '1 background job running' }) + const trigger = page.getByRole('button', { name: '1 task running' }) expect(await trigger.count()).toBe(0) const started = await scaffold.ctx.tools.execute({ @@ -97,7 +97,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { await trigger.waitFor({ timeout: 15_000 }) await trigger.click() - const row = page.getByRole('list', { name: 'Background jobs' }).getByRole('listitem').first() + const row = page.getByRole('list', { name: 'Tasks' }).getByRole('listitem').first() await row.waitFor({ timeout: 10_000 }) await expect.poll(() => row.textContent()).toContain(COMMAND) @@ -111,7 +111,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled')) expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') - const idle = page.getByRole('button', { name: '1 background job' }) + const idle = page.getByRole('button', { name: '1 task' }) await idle.waitFor({ timeout: 20_000 }) const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/live-activity-stream.e2e.ts b/apps/web/tests/live-activity-stream.e2e.ts index 750e1697f3..b8a702e738 100644 --- a/apps/web/tests/live-activity-stream.e2e.ts +++ b/apps/web/tests/live-activity-stream.e2e.ts @@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { it('streams a running background command\'s real output into the expanded panel', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-streaming')) - const trigger = page.getByRole('button', { name: '1 live activity' }) + const trigger = page.getByRole('button', { name: '1 task running' }) expect(await trigger.count()).toBe(0) const started = await scaffold.ctx.tools.execute({ @@ -118,7 +118,7 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { // Settlement arrives on the observation stream itself, so the open panel // flips without any further interaction; the roster trigger follows. - const idle = page.getByRole('button', { name: '1 activity' }) + const idle = page.getByRole('button', { name: '1 task' }) await idle.waitFor({ timeout: 20_000 }) await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/snapshots/background-job-list/running.expected.md b/apps/web/tests/snapshots/background-job-list/running.expected.md index 62a413bf4c..95f226f226 100644 --- a/apps/web/tests/snapshots/background-job-list/running.expected.md +++ b/apps/web/tests/snapshots/background-job-list/running.expected.md @@ -1,2 +1,5 @@ -- list "Background jobs": - - listitem: bash sleep 45 running {{duration}} +- list "Tasks": + - listitem: + - button "Show live output of sleep 45": + - text: sleep 45 {{duration}} bash running + - img diff --git a/apps/web/tests/snapshots/background-job-list/settled.expected.md b/apps/web/tests/snapshots/background-job-list/settled.expected.md index cc4bcfef74..3343ae521c 100644 --- a/apps/web/tests/snapshots/background-job-list/settled.expected.md +++ b/apps/web/tests/snapshots/background-job-list/settled.expected.md @@ -1,2 +1,5 @@ -- list "Background jobs": - - listitem: "bash sleep 45 signal: SIGTERM {{duration}}" +- list "Tasks": + - listitem: + - button "Show live output of sleep 45": + - text: "bash sleep 45 signal: SIGTERM {{duration}}" + - img diff --git a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md index 7ffec602cb..2090dca51d 100644 --- a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md +++ b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md @@ -1,7 +1,7 @@ -- list "Live activities": +- list "Tasks": - listitem: - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM" + - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM {{duration}}" - img - text: done $ printf 'streamed-%s\n' marker-line; sleep 45 - button "Copy" diff --git a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md index fd536ef430..ea906239aa 100644 --- a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md +++ b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md @@ -1,7 +1,7 @@ -- list "Live activities": +- list "Tasks": - listitem: - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - - text: bash printf 'streamed-%s\n' marker-line; sleep 45 running + - text: printf 'streamed-%s\n' marker-line; sleep 45 {{duration}} bash running - img - text: running $ printf 'streamed-%s\n' marker-line; sleep 45 - button "Copy" diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 6c2e824a55..43c0fd7cd0 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 0bf883bbfde80377602be90bd913c32f1b320f63 -config-catalog.zh.md: 381319465dab9dafeecaacf704caba8bab7455f3 +config-catalog.md: f6c751b0942144f29a648491381fecd307e2ed8f +config-catalog.zh.md: ac16ec94533b6c818f2abb00d5ed96b3d81a07ac diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0bf883bbfd..f6c751b094 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3355,7 +3355,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-input-trigger` ([`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-jobs` ([`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-message-feedback` ([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model-selection` ([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 381319465d..ac16ec9453 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3357,7 +3357,6 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-directory-picker-native`([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-input-trigger`([`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-jobs`([`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout`([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-message-feedback`([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model-selection`([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a467f4aea6..52cd807255 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 9d192404f4949bcf6e5c4222f0b898cc52362bd8 -module-graph.zh.md: bbdfd5642238644939fd08c02b9786310bad63e2 +module-graph.md: 8664f078fdcec3b8a2474bdf62d20fbb6dfe9525 +module-graph.zh.md: 3975d691877e2e5aeaade32a5530fd05e7d4acbb diff --git a/docs/module-graph.md b/docs/module-graph.md index 9d192404f4..8664f078fd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -104,7 +104,12 @@ flowchart TD subgraph group_acp["packages/acp"] pkg_acp["acp"] end + subgraph group_activity["packages/activity"] + pkg_activity["activity"] + pkg_activity_local["activity-local"] + end subgraph group_api["packages/api"] + pkg_api_activity_controller["api-activity-controller"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] @@ -132,6 +137,7 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_store["client-store"] + pkg_client_ui_activity["client-ui-activity"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] @@ -144,7 +150,6 @@ flowchart TD pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] - pkg_client_ui_jobs["client-ui-jobs"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_message_feedback["client-ui-message-feedback"] pkg_client_ui_model_selection["client-ui-model-selection"] @@ -675,6 +680,12 @@ flowchart TD pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell + pkg_activity --> pkg_agent + pkg_activity --> pkg_brand + pkg_activity --> pkg_invariants + pkg_activity --> pkg_jobs + pkg_activity --> pkg_llm + pkg_activity --> pkg_session pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model pkg_headless --> pkg_invariants @@ -829,6 +840,10 @@ flowchart TD pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools + pkg_activity_local --> pkg_activity + pkg_activity_local --> pkg_agent + pkg_activity_local --> pkg_invariants + pkg_activity_local --> pkg_scope pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction pkg_command_compact --> pkg_invariants @@ -965,6 +980,7 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_tool_workflow --> pkg_activity pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm @@ -1026,6 +1042,7 @@ flowchart TD pkg_tool_cordis --> pkg_tools pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_invariants + pkg_tool_bash --> pkg_activity pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1037,6 +1054,7 @@ flowchart TD pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_activity pkg_tool_pwsh --> pkg_agent pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_jobs @@ -1242,6 +1260,16 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_api_activity_controller --> pkg_activity + pkg_api_activity_controller --> pkg_agent + pkg_api_activity_controller --> pkg_api_gateway + pkg_api_activity_controller --> pkg_client_connection + pkg_api_activity_controller --> pkg_client_store + pkg_api_activity_controller --> pkg_invariants + pkg_api_activity_controller --> pkg_jobs + pkg_api_activity_controller --> pkg_llm + pkg_api_activity_controller --> pkg_session + pkg_api_activity_controller --> pkg_typert_protocol pkg_api_session_controller --> pkg_agent pkg_api_session_controller --> pkg_agent_default_model pkg_api_session_controller --> pkg_agent_presets @@ -1272,6 +1300,7 @@ flowchart TD pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace pkg_api_remotes --> pkg_agent_presets + pkg_api_remotes --> pkg_api_activity_controller pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller pkg_api_remotes --> pkg_api_workspace_controller @@ -1382,6 +1411,13 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_session pkg_client_ui_workspace --> pkg_util_workspace_path + pkg_client_ui_activity --> pkg_api_activity_controller + pkg_client_ui_activity --> pkg_api_session_controller + pkg_client_ui_activity --> pkg_client_locale + pkg_client_ui_activity --> pkg_client_ui_conversation + pkg_client_ui_activity --> pkg_client_ui_renderer + pkg_client_ui_activity --> pkg_client_ui_session + pkg_client_ui_activity --> pkg_invariants pkg_client_ui_agent_preset --> pkg_api_remotes pkg_client_ui_agent_preset --> pkg_api_session_controller pkg_client_ui_agent_preset --> pkg_client_connection @@ -1423,12 +1459,6 @@ flowchart TD pkg_client_ui_input_trigger --> pkg_file_reference pkg_client_ui_input_trigger --> pkg_invariants pkg_client_ui_input_trigger --> pkg_session - pkg_client_ui_jobs --> pkg_api_session_controller - pkg_client_ui_jobs --> pkg_client_locale - pkg_client_ui_jobs --> pkg_client_ui_conversation - pkg_client_ui_jobs --> pkg_client_ui_renderer - pkg_client_ui_jobs --> pkg_client_ui_session - pkg_client_ui_jobs --> pkg_invariants pkg_client_ui_plan --> pkg_api_remotes pkg_client_ui_plan --> pkg_client_locale pkg_client_ui_plan --> pkg_client_ui_conversation @@ -1757,6 +1787,7 @@ flowchart TD | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | +| [`activity`](../packages/activity/activity) | `activity` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | @@ -1783,6 +1814,7 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`activity-local`](../packages/activity/activity-local) | `activity` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1807,7 +1839,7 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | @@ -1816,8 +1848,8 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1846,9 +1878,10 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`api-activity-controller`](../packages/api/activity-controller) | `api` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-activity-controller`](../packages/api/activity-controller), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1861,13 +1894,13 @@ flowchart TD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-activity`](../packages/client/ui-activity) | `client` | [`api-activity-controller`](../packages/api/activity-controller), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index bbdfd56422..3975d69187 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -106,7 +106,12 @@ flowchart TD subgraph group_acp["packages/acp"] pkg_acp["acp"] end + subgraph group_activity["packages/activity"] + pkg_activity["activity"] + pkg_activity_local["activity-local"] + end subgraph group_api["packages/api"] + pkg_api_activity_controller["api-activity-controller"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] @@ -134,6 +139,7 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_store["client-store"] + pkg_client_ui_activity["client-ui-activity"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] @@ -146,7 +152,6 @@ flowchart TD pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] - pkg_client_ui_jobs["client-ui-jobs"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_message_feedback["client-ui-message-feedback"] pkg_client_ui_model_selection["client-ui-model-selection"] @@ -677,6 +682,12 @@ flowchart TD pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell + pkg_activity --> pkg_agent + pkg_activity --> pkg_brand + pkg_activity --> pkg_invariants + pkg_activity --> pkg_jobs + pkg_activity --> pkg_llm + pkg_activity --> pkg_session pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model pkg_headless --> pkg_invariants @@ -831,6 +842,10 @@ flowchart TD pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools + pkg_activity_local --> pkg_activity + pkg_activity_local --> pkg_agent + pkg_activity_local --> pkg_invariants + pkg_activity_local --> pkg_scope pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction pkg_command_compact --> pkg_invariants @@ -967,6 +982,7 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_tool_workflow --> pkg_activity pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm @@ -1028,6 +1044,7 @@ flowchart TD pkg_tool_cordis --> pkg_tools pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_invariants + pkg_tool_bash --> pkg_activity pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1039,6 +1056,7 @@ flowchart TD pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_activity pkg_tool_pwsh --> pkg_agent pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_jobs @@ -1244,6 +1262,16 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_api_activity_controller --> pkg_activity + pkg_api_activity_controller --> pkg_agent + pkg_api_activity_controller --> pkg_api_gateway + pkg_api_activity_controller --> pkg_client_connection + pkg_api_activity_controller --> pkg_client_store + pkg_api_activity_controller --> pkg_invariants + pkg_api_activity_controller --> pkg_jobs + pkg_api_activity_controller --> pkg_llm + pkg_api_activity_controller --> pkg_session + pkg_api_activity_controller --> pkg_typert_protocol pkg_api_session_controller --> pkg_agent pkg_api_session_controller --> pkg_agent_default_model pkg_api_session_controller --> pkg_agent_presets @@ -1274,6 +1302,7 @@ flowchart TD pkg_api_workspace_controller --> pkg_typert_protocol pkg_api_workspace_controller --> pkg_workspace pkg_api_remotes --> pkg_agent_presets + pkg_api_remotes --> pkg_api_activity_controller pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller pkg_api_remotes --> pkg_api_workspace_controller @@ -1384,6 +1413,13 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_session pkg_client_ui_workspace --> pkg_util_workspace_path + pkg_client_ui_activity --> pkg_api_activity_controller + pkg_client_ui_activity --> pkg_api_session_controller + pkg_client_ui_activity --> pkg_client_locale + pkg_client_ui_activity --> pkg_client_ui_conversation + pkg_client_ui_activity --> pkg_client_ui_renderer + pkg_client_ui_activity --> pkg_client_ui_session + pkg_client_ui_activity --> pkg_invariants pkg_client_ui_agent_preset --> pkg_api_remotes pkg_client_ui_agent_preset --> pkg_api_session_controller pkg_client_ui_agent_preset --> pkg_client_connection @@ -1425,12 +1461,6 @@ flowchart TD pkg_client_ui_input_trigger --> pkg_file_reference pkg_client_ui_input_trigger --> pkg_invariants pkg_client_ui_input_trigger --> pkg_session - pkg_client_ui_jobs --> pkg_api_session_controller - pkg_client_ui_jobs --> pkg_client_locale - pkg_client_ui_jobs --> pkg_client_ui_conversation - pkg_client_ui_jobs --> pkg_client_ui_renderer - pkg_client_ui_jobs --> pkg_client_ui_session - pkg_client_ui_jobs --> pkg_invariants pkg_client_ui_plan --> pkg_api_remotes pkg_client_ui_plan --> pkg_client_locale pkg_client_ui_plan --> pkg_client_ui_conversation @@ -1759,6 +1789,7 @@ flowchart TD | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | +| [`activity`](../packages/activity/activity) | `activity` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | @@ -1785,6 +1816,7 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`activity-local`](../packages/activity/activity-local) | `activity` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1809,7 +1841,7 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | @@ -1818,8 +1850,8 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1848,9 +1880,10 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`api-activity-controller`](../packages/api/activity-controller) | `api` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-activity-controller`](../packages/api/activity-controller), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1863,13 +1896,13 @@ flowchart TD | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-activity`](../packages/client/ui-activity) | `client` | [`api-activity-controller`](../packages/api/activity-controller), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 9fa12bc33f..7e66b4fafd 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -273,9 +273,6 @@ - id: ui-reference name: '@deepseek-ai/dsh-client-ui-reference' - # Background jobs: the session-header list over the jobsBySession mirror. - - id: ui-jobs - name: '@deepseek-ai/dsh-client-ui-jobs' # Live activities: the session-header roster with on-demand streaming # output panels over the activity feed. diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 0837c9f6b1..c829d4a089 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -68,7 +68,6 @@ "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index db771381dd..31e1304a21 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: eaf01b282ead3c4638435e3d02d6a803ad1faa15 -README.zh.md: 2ff4b1529f07e0f31b3d06a9577d3c480dd34916 +README.md: 928824bbdc92d933429c220572d0e5e80bcea989 +README.zh.md: 2978efc916930bc3742ca5da546091b0e8d70d5c diff --git a/packages/client/README.md b/packages/client/README.md index eaf01b282e..928824bbdc 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -35,7 +35,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. | | [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source. | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. | -| [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs in the conversation header. | +| [`ui-activity/`](ui-activity/README.md) | Lists this session's tasks — jobs joined with their live-output activities. | | [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces. | | [`ui-permission/`](ui-permission-presets/README.md) | Configures default permissions and switches the current session's access. | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 2ff4b1529f..2978efc916 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -35,7 +35,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用。 | | [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source。 | | [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent(子 agent)导航、子级 transcript(文本记录)的状态和内联引用。 | -| [`ui-jobs/`](ui-jobs/README.zh.md) | 在会话标题栏列出当前会话的后台任务。 | +| [`ui-activity/`](ui-activity/README.zh.md) | 列出本会话的任务——后台任务与其实时输出 activity 的合并视图。 | | [`ui-model-selection/`](ui-model-selection/README.zh.md) | 在对话界面中提供模型选择。 | | [`ui-permission/`](ui-permission-presets/README.zh.md) | 配置默认权限并切换当前会话的访问模式。 | | [`ui-plan/`](ui-plan/README.zh.md) | 展示生效中的 plan mode 状态及其退出控件。 | diff --git a/packages/client/ui-activity/README.i18n.yaml b/packages/client/ui-activity/README.i18n.yaml index 532ea19b10..ddfc1e3f6f 100644 --- a/packages/client/ui-activity/README.i18n.yaml +++ b/packages/client/ui-activity/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-activity/README.md -README.md: 157e98e6b14bc1b556cb2aac8f6f90c43fbcb3f2 -README.zh.md: 392cc91c105caa2f79546f818700da0e6643b8a1 +README.md: beb86e0c9f685c2ff7f799f878bb8646a6c2d041 +README.zh.md: 2aebde5da685995f6c191d621aed0ad3bac56678 diff --git a/packages/client/ui-activity/README.md b/packages/client/ui-activity/README.md index 157e98e6b1..beb86e0c9f 100644 --- a/packages/client/ui-activity/README.md +++ b/packages/client/ui-activity/README.md @@ -2,13 +2,15 @@ English | [中文](README.zh.md) -Session-header live-activity list over `ctx.activityFeed`: one header action beside the background-job list renders the roster rows this session can see (owned plus unowned), and expanding a row opens that activity's observation stream into an embedded terminal panel. Observation follows visibility — the stream opens on expand and closes on collapse, unmount, or popover dismissal, so output only flows while someone is watching. Retention gaps and stream failures render as notices above the panel. +Session-header task list: one header action renders this session's background jobs joined with their correlated live-output activities, plus standalone activities such as workflow runs. The join happens per row in this component — jobs and activities stay separate planes ([why](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md)): a job row keeps the job's lifecycle, duration, and model-visible `detail` from the `jobsBySession` mirror, and gains an expandable output panel when an activity carries its `correlation.jobId`; an activity without a job keeps its own row. Live rows lead with the label over a kind-and-status second line and a ticking duration; settled rows compact to one de-emphasized line behind a section divider. -The control renders nothing until the session can see at least one activity, so an ordinary conversation never grows a control for a capability it is not using; without the activity registry the roster stays empty and the entry point never appears. +Expanding an observable row opens that activity's observation stream from `ctx.activityFeed` into an embedded terminal panel. Observation follows visibility — the stream opens on expand and closes on collapse, unmount, or popover dismissal, so output only flows while someone is watching. Retention gaps and stream failures render as notices above the panel. + +The control renders nothing until the session can see at least one task, so an ordinary conversation never grows a control for a capability it is not using. Without the activity registry, job rows still render from the session mirror — they just offer no output panel. ## Model Experience -None, as this package renders host-observed live output for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). +None, as this package renders host-observed state and live output for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). #### KV Cache effect @@ -18,4 +20,3 @@ None; the package never assembles or sends provider requests. - **No kill control** — cancellation stays with the model's `job_kill`; a human kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). - **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up. -- **A background job appears in both header lists** — the job list carries control-facing status, this list carries output; the duplication mirrors the deliberate subagent-catalog precedent. diff --git a/packages/client/ui-activity/README.zh.md b/packages/client/ui-activity/README.zh.md index 392cc91c10..2aebde5da6 100644 --- a/packages/client/ui-activity/README.zh.md +++ b/packages/client/ui-activity/README.zh.md @@ -2,13 +2,15 @@ [English](README.md) | 中文 -架在 `ctx.activityFeed` 上的会话头部实时活动列表:在后台任务列表旁提供一个头部 action,渲染本会话可见的 roster 行(有主 + 无主),展开某行即把该 activity 的观察流打开进内嵌终端面板。观察跟随可见性——展开开流,收起、卸载或关闭弹层即停流,只有有人在看时输出才流动。保留缺口与流失败以面板上方的提示呈现。 +会话头部任务列表:一个头部 action 渲染本会话的后台任务与其关联的实时输出 activity 的合并视图,外加 workflow 运行等独立 activity。合并逐行发生在本组件内——jobs 与 activity 仍是两个独立的面([缘由](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)):job 行保留来自 `jobsBySession` 镜像的生命周期、时长与模型可见 `detail`,当某个 activity 以 `correlation.jobId` 关联到它时获得可展开的输出面板;没有 job 的 activity 保留自己的行。进行中的行以命令为主行、kind 与状态为副行并带跳动时长;已结束的行折为降权单行,置于分节线之后。 -会话一个 activity 都看不到时该控件完全不渲染,普通会话不会为未使用的能力多出控件;没有 activity 注册表时 roster 恒空,入口永不出现。 +展开可观察的行即把该 activity 在 `ctx.activityFeed` 上的观察流打开进内嵌终端面板。观察跟随可见性——展开开流,收起、卸载或关闭弹层即停流,只有有人在看时输出才流动。保留缺口与流失败以面板上方的提示呈现。 + +会话一个任务都看不到时该控件完全不渲染,普通会话不会为未使用的能力多出控件。没有 activity 注册表时,job 行仍从会话镜像渲染——只是不提供输出面板。 ## 模型体验 -无:本包只为人渲染宿主观察到的实时输出,不触碰任何 prompt、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 +无:本包只为人渲染宿主观察到的状态与实时输出,不触碰任何 prompt、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 #### KV Cache 影响 @@ -18,4 +20,3 @@ - **没有 kill 控件**——取消仍走模型的 `job_kill`;人工 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。 - **不渲染通道标签**——stdout 与 stderr chunk 连接成一条流;分通道着色是后续呈现优化。 -- **后台 job 会同时出现在两个头部列表**——job 列表承载控制侧状态,本列表承载输出;这一重复沿用 subagent catalog 的既有取舍。 diff --git a/packages/client/ui-activity/package.json b/packages/client/ui-activity/package.json index 83395df003..4c4282e60b 100644 --- a/packages/client/ui-activity/package.json +++ b/packages/client/ui-activity/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-activity", - "description": "Session-header live-activity list: roster rows from the activity control stream with on-demand streaming output panels", + "description": "Session-header task list: background jobs joined with their live-output activities, with on-demand streaming output panels", "version": "0.1.1-rc.2", "type": "module", "main": "lib/index.js", @@ -37,6 +37,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-activity-controller": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", @@ -46,6 +47,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-activity-controller": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-activity/src/client/ActivityListAction.module.css b/packages/client/ui-activity/src/client/ActivityListAction.module.css index 2cf3af6e2f..d0d632ab34 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.module.css +++ b/packages/client/ui-activity/src/client/ActivityListAction.module.css @@ -62,6 +62,16 @@ box-shadow: var(--dsw-shadow-lv3); } +/* Divider label between the live section and the settled tail. */ +.sectionHeader { + margin: 4px 4px 1px; + padding: 4px 4px 3px; + border-top: 1px solid var(--dsw-alias-border-l1); + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + .item { display: flex; flex-direction: column; @@ -90,14 +100,46 @@ background: var(--dsw-alias-fill-l1); } +/* A row with no observable output offers no interaction. */ +.rowStatic { + cursor: default; +} + +.rowStatic:hover { + background: transparent; +} + .rowSettled { color: var(--dsw-alias-label-tertiary); } -.rowDot { +/* Live rows align the dot with the first text line, not the row center. */ +.row .rowDot { flex: none; } +.main { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.primary { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.secondary { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + .kind { flex: none; padding: 0 6px; @@ -117,11 +159,11 @@ text-overflow: ellipsis; } -/* A failed activity's detail is the producer's raw text, so it has no bound; +/* A failed task's detail is the producer's raw text, so it has no bound; without this it widens the row past the menu instead of eliding like .label. */ .status { flex: none; - max-width: 40%; + max-width: 60%; overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 11px; @@ -130,6 +172,14 @@ text-overflow: ellipsis; } +.duration { + flex: none; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 18px; + font-variant-numeric: tabular-nums; +} + .chevron { flex: none; color: var(--dsw-alias-label-tertiary); @@ -139,8 +189,11 @@ transform: rotate(180deg); } +/* Output hangs under its row behind a spine that ties the two together. */ .panel { - margin: 0 8px 8px; + margin: 0 8px 8px 14px; + padding-left: 8px; + border-left: 2px solid var(--dsw-alias-border-l2); } .notice { diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-activity/src/client/ActivityListAction.tsx index 1f868988b0..ef77df0e9c 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.tsx +++ b/packages/client/ui-activity/src/client/ActivityListAction.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEve import type { ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, } from '@deepseek-ai/dsh-api-activity-controller/client' +import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' import { IconChevronDownOutline14, StateDot, TerminalBlock, useDismissOnOutsidePointer, type StateDotState, type TerminalBlockLabels, @@ -27,14 +28,36 @@ export interface ActivityListInjected { observe: (id: ActivityId) => () => void } -/** Full props for the session-header live-activity action. */ +/** Full props for the session-header task-list action. */ export type ActivityListActionProps = PropsRuntime<'conversation.session.header.actions'> & PropsLocale & InjectFace -/** Stable empty list so a session with no activities keeps one array identity. */ +/** + * One merged task row. Jobs and activities are separate planes — the control + * plane the model interacts with, and the transient observation plane — so + * this list joins them per row: a job carries identity, lifecycle, and the + * model-visible detail; its correlated activity contributes the observable + * live output; an activity without a job (a workflow run) stands alone. + */ +interface TaskRow { + /** Stable row identity: the job id when one exists, else the activity id. */ + key: string + kind: string + label: string + /** Job lifecycle vocabulary; activity-only rows never reach `stopping`. */ + status: SessionJob['status'] + detail?: string + startedAt: number + finishedAt?: number + /** Present when this row's live output can be observed. */ + activityId?: ActivityId +} + +/** Stable empty lists so a session with no work keeps one array identity. */ const NO_ROWS: readonly ActivityRow[] = [] +const NO_JOBS: readonly SessionJob[] = [] /** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */ const VIEWPORT_MARGIN = 12 @@ -42,19 +65,24 @@ const VIEWPORT_MARGIN = 12 /** Height cap for one live output panel inside the popover. */ const PANEL_MAX_LINES = 16 -function isLive(row: ActivityRow): boolean { - return row.status === 'running' +function isLive(row: TaskRow): boolean { + return row.status === 'running' || row.status === 'stopping' } /** Closed-union exhaustiveness fence for the wire status set. */ /* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ function assertNever(value: never): never { - throw new Error(`unhandled activity status: ${JSON.stringify(value)}`) + throw new Error(`unhandled task status: ${JSON.stringify(value)}`) } -function dotState(status: ActivityRow['status']): StateDotState { +/** + * Status marker semantics. `stopping` and `killed` share the attention color: + * both mean the work ended (or is ending) on request rather than on its own. + */ +function dotState(status: TaskRow['status']): StateDotState { switch (status) { case 'running': return 'ongoing' + case 'stopping': return 'warning' case 'completed': return 'done' case 'killed': return 'warning' case 'failed': return 'error' @@ -63,9 +91,10 @@ function dotState(status: ActivityRow['status']): StateDotState { } } -function statusLabel(status: ActivityRow['status'], t: TranslateNS): string { +function statusLabel(status: TaskRow['status'], t: TranslateNS): string { switch (status) { case 'running': return t('status.running') + case 'stopping': return t('status.stopping') case 'completed': return t('status.completed') case 'killed': return t('status.killed') case 'failed': return t('status.failed') @@ -74,6 +103,22 @@ function statusLabel(status: ActivityRow['status'], t: TranslateNS): } } +/** + * Elapsed time in at most two adjacent units. A task that outlives an hour is + * already exceptional, so hours is the widest unit — beyond that the figure + * stays in hours rather than growing a day/month vocabulary no producer + * currently reaches. + */ +function formatDuration(elapsedMs: number, t: TranslateNS): string { + const total = Math.max(0, Math.floor(elapsedMs / 1_000)) + const seconds = total % 60 + const minutes = Math.floor(total / 60) % 60 + const hours = Math.floor(total / 3_600) + if (hours > 0) return t('duration.hours', { hours, minutes }) + if (minutes > 0) return t('duration.minutes', { minutes, seconds }) + return t('duration.seconds', { seconds }) +} + /** Localized display copy for the embedded terminal panel. */ function terminalLabels(t: TranslateNS): TerminalBlockLabels { return { @@ -96,9 +141,63 @@ function terminalLabels(t: TranslateNS): TerminalBlockLabels { } } -/* jscpd:ignore-start -- mirrors JobListAction's ordering and trigger chrome by design so the two header lists read as one family. */ -/** Live rows first in start order, then settled rows newest-first. */ -function ordered(rows: readonly ActivityRow[]): ActivityRow[] { +/** Project one standalone activity into the merged row vocabulary. */ +function activityTask(activity: ActivityRow): TaskRow { + return { + key: String(activity.id), + kind: activity.kind, + label: activity.label, + status: activity.status, + ...activity.detail !== undefined ? { detail: activity.detail } : {}, + startedAt: activity.startedAt, + ...activity.finishedAt !== undefined ? { finishedAt: activity.finishedAt } : {}, + activityId: activity.id, + } +} + +/** + * Join this session's jobs with their correlated activities and append the + * activities that stand alone. A job row keeps the job's lifecycle and detail + * (what the model sees) and gains observability from its activity; an + * activity whose job is not in the projection keeps its own row rather than + * disappearing. + */ +function mergeRows( + jobs: readonly SessionJob[], + activities: readonly ActivityRow[], +): TaskRow[] { + const byJob = new Map() + const standalone: ActivityRow[] = [] + for (const activity of activities) { + const jobId = activity.correlation?.jobId + if (jobId === undefined) standalone.push(activity) + else byJob.set(String(jobId), activity) + } + const rows: TaskRow[] = jobs.map((job) => { + const activity = byJob.get(String(job.id)) + byJob.delete(String(job.id)) + return { + key: String(job.id), + kind: job.kind, + label: job.label, + status: job.status, + ...job.detail !== undefined ? { detail: job.detail } : {}, + startedAt: job.startedAt, + ...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {}, + ...activity !== undefined ? { activityId: activity.id } : {}, + } + }) + for (const activity of byJob.values()) rows.push(activityTask(activity)) + for (const activity of standalone) rows.push(activityTask(activity)) + return rows +} + +/** + * Live rows first in start order, then settled rows newest-first. Two rows + * that settled in the same millisecond fall back to start order, so the sort + * never depends on the host's map iteration. + */ +function ordered(rows: readonly TaskRow[]): TaskRow[] { return [...rows].sort((left, right) => { const liveLeft = isLive(left) if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 @@ -107,44 +206,87 @@ function ordered(rows: readonly ActivityRow[]): ActivityRow[] { return finished !== 0 ? finished : left.startedAt - right.startedAt }) } -/* jscpd:ignore-end */ -/** One expandable roster row plus its on-demand live output panel. */ -function ActivityItem({ row, view, expanded, onToggle, t }: { - row: ActivityRow +/** One task row plus, when observable and expanded, its live output panel. */ +function TaskItem({ row, view, expanded, now, onToggle, t }: { + row: TaskRow view: ObservedActivity | undefined expanded: boolean + /** Clock sample live rows derive their running duration from. */ + now: number onToggle: () => void t: TranslateNS }) { const live = isLive(row) const status = statusLabel(row.status, t) + const observable = row.activityId !== undefined const labels = useMemo(() => terminalLabels(t), [t]) - return ( -
      • - - {expanded + {durationCell} + {observable ? : null} + + ) + return ( +
      • + {observable + ? ( + + ) + : ( + + {body} + + )} + {expanded && view !== undefined ? (
        - {view?.gapBefore === true ?
        {t('output.gap')}
        : null} - {view?.error !== undefined + {view.gapBefore ?
        {t('output.gap')}
        : null} + {view.error !== undefined ?
        {t('output.error', { error: view.error })}
        : null} state.jobsBySession[sessionId]) ?? NO_JOBS const owned = useActivity(state => state.rowsBySession[sessionId]) ?? NO_ROWS const unowned = useActivity(state => state.rowsBySession['']) ?? NO_ROWS const observedViews = useActivity(state => state.observed) const [open, setOpen] = useState(false) - const [expandedId, setExpandedId] = useState(undefined) + const [expandedKey, setExpandedKey] = useState(undefined) + const [now, setNow] = useState(() => Date.now()) const rootRef = useRef(null) const triggerRef = useRef(null) const menuRef = useRef(null) @@ -178,11 +324,23 @@ export function ActivityListAction({ sessionId, useActivity, observe, t }: Activ // inside the viewport (the stylesheet alone cannot see the anchor offset). const [menuShift, setMenuShift] = useState(0) - const rows = useMemo(() => ordered([...owned, ...unowned]), [owned, unowned]) - const liveCount = useMemo(() => rows.filter(isLive).length, [rows]) + const rows = useMemo( + () => ordered(mergeRows(jobs, [...owned, ...unowned])), + [jobs, owned, unowned], + ) + const liveRows = useMemo(() => rows.filter(isLive), [rows]) + const settledRows = useMemo(() => rows.filter(row => !isLive(row)), [rows]) useDismissOnOutsidePointer(rootRef, open, setOpen) + // The clock only runs while an open list is showing something that moves. + useEffect(() => { + if (!open || liveRows.length === 0) return + setNow(Date.now()) + const timer = setInterval(() => { setNow(Date.now()) }, 1_000) + return () => { clearInterval(timer) } + }, [open, liveRows.length]) + // Fit the open popover to the viewport: shift left when the anchored width // would cross the right edge, never past the left margin. useLayoutEffect(() => { @@ -209,35 +367,36 @@ export function ActivityListAction({ sessionId, useActivity, observe, t }: Activ return () => { window.removeEventListener('resize', fit) } }, [open]) - // Observation follows visibility: the stream opens when a panel expands and - // closes when it collapses, unmounts, or the popover closes. - const activeId = open ? expandedId : undefined + // Observation follows visibility: the stream opens when an observable panel + // expands and closes when it collapses, unmounts, or the popover closes. + const activeActivity = open && expandedKey !== undefined + ? rows.find(row => row.key === expandedKey)?.activityId + : undefined useEffect(() => { - if (activeId === undefined) return - return observe(activeId as ActivityId) - }, [activeId, observe]) + if (activeActivity === undefined) return + return observe(activeActivity) + }, [activeActivity, observe]) - // The last activity disappearing removes this control; close first so focus + // The last task disappearing removes this control; close first so focus // does not vanish from an unmounting node. useEffect(() => { if (rows.length === 0 && open) setOpen(false) }, [rows.length, open]) - // An expanded row that left the roster (owner disposal) folds its panel. + // An expanded row that left the list (owner disposal) folds its panel. useEffect(() => { - if (expandedId !== undefined && !rows.some(row => String(row.id) === expandedId)) { - setExpandedId(undefined) + if (expandedKey !== undefined && !rows.some(row => row.key === expandedKey)) { + setExpandedKey(undefined) } - }, [rows, expandedId]) + }, [rows, expandedKey]) if (rows.length === 0) return null - const countKey = liveCount > 0 - ? (liveCount === 1 ? 'count.live.one' : 'count.live.other') + const countKey = liveRows.length > 0 + ? (liveRows.length === 1 ? 'count.live.one' : 'count.live.other') : (rows.length === 1 ? 'count.idle.one' : 'count.idle.other') - const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : rows.length }) + const countLabel = t(countKey, { count: liveRows.length > 0 ? liveRows.length : rows.length }) - /* jscpd:ignore-start -- mirrors JobListAction's dismiss/trigger interaction by design. */ const onKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape' || !open) return event.preventDefault() @@ -245,6 +404,20 @@ export function ActivityListAction({ sessionId, useActivity, observe, t }: Activ triggerRef.current?.focus() } + const item = (row: TaskRow) => ( + { + setExpandedKey(current => current === row.key ? undefined : row.key) + }} + t={t} + /> + ) + return (
        - {/* jscpd:ignore-end */} {open ? (
          - {rows.map(row => ( - { - setExpandedId(current => current === String(row.id) ? undefined : String(row.id)) - }} - t={t} - /> - ))} + {liveRows.map(item)} + {liveRows.length > 0 && settledRows.length > 0 + ? + : null} + {settledRows.map(item)}
        ) : null} diff --git a/packages/client/ui-activity/src/client/index.ts b/packages/client/ui-activity/src/client/index.ts index 83ce0aa7bf..39f43df9e1 100644 --- a/packages/client/ui-activity/src/client/index.ts +++ b/packages/client/ui-activity/src/client/index.ts @@ -1,8 +1,10 @@ /** - * Live-activity plugin, browser half: contributes one session-header action - * that renders the activity roster and, per expanded row, an on-demand - * observation stream's live output. All data arrives through the - * `activityFeed` client service; this plugin holds no transport state. + * Task-list plugin, browser half: contributes one session-header action that + * renders this session's tasks — background jobs joined with their correlated + * live-output activities, plus standalone activities such as workflow runs. + * Job rows arrive through the `jobsBySession` list mirror; activity rows and + * per-row observation streams arrive through the `activityFeed` client + * service. This plugin holds no transport state of its own. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import { ActivityListAction } from './ActivityListAction.tsx' @@ -22,8 +24,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { export type { ActivityListActionProps, ActivityListInjected } from './ActivityListAction.tsx' -/** Required services: the activity feed, the slot registry, and dictionaries. */ -export const inject = ['activityFeed', 'slots', 'locale'] +/** Required services: session and activity state, the slot registry, and dictionaries. */ +export const inject = ['sessions', 'activityFeed', 'slots', 'locale'] /** * Client plugin body: register the dictionaries and the header action. @@ -36,8 +38,8 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'activity-list', - // After the background-job list: job control reads before live output. - order: 30, + // After the subagent catalog: session lineage reads before running work. + order: 20, locale: NS, inject: (): ActivityListInjected => ({ hooks: { activity: ctx.activityFeed.state }, diff --git a/packages/client/ui-activity/src/client/locales.ts b/packages/client/ui-activity/src/client/locales.ts index efea5126da..acb69c075d 100644 --- a/packages/client/ui-activity/src/client/locales.ts +++ b/packages/client/ui-activity/src/client/locales.ts @@ -5,17 +5,24 @@ export const NS = 'activity' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'count.live.one': '{count} 个活动进行中', - 'count.live.other': '{count} 个活动进行中', - 'count.idle.one': '{count} 个活动', - 'count.idle.other': '{count} 个活动', - 'list.aria': '实时活动', + 'count.live.one': '{count} 个任务进行中', + 'count.live.other': '{count} 个任务进行中', + 'count.idle.one': '{count} 个任务', + 'count.idle.other': '{count} 个任务', + 'list.aria': '任务', + 'section.settled': '已结束', 'row.expandAria': '展开 {label} 的实时输出', 'row.collapseAria': '收起 {label} 的实时输出', 'status.running': '运行中', + 'status.stopping': '正在停止', 'status.completed': '已完成', 'status.killed': '已取消', 'status.failed': '已失败', + 'duration.seconds': '{seconds}秒', + 'duration.minutes': '{minutes}分{seconds}秒', + 'duration.hours': '{hours}小时{minutes}分', + 'duration.title.live': '已运行 {duration}', + 'duration.title.done': '耗时 {duration}', 'output.gap': '……较早的输出已丢弃……', 'output.error': '实时输出流中断:{error}', 'terminal.signal': '信号 {signal}', @@ -34,17 +41,24 @@ export const zh = { /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { - 'count.live.one': '{count} live activity', - 'count.live.other': '{count} live activities', - 'count.idle.one': '{count} activity', - 'count.idle.other': '{count} activities', - 'list.aria': 'Live activities', + 'count.live.one': '{count} task running', + 'count.live.other': '{count} tasks running', + 'count.idle.one': '{count} task', + 'count.idle.other': '{count} tasks', + 'list.aria': 'Tasks', + 'section.settled': 'Finished', 'row.expandAria': 'Show live output of {label}', 'row.collapseAria': 'Hide live output of {label}', 'status.running': 'running', + 'status.stopping': 'stopping', 'status.completed': 'completed', 'status.killed': 'cancelled', 'status.failed': 'failed', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m', + 'duration.title.live': 'Running for {duration}', + 'duration.title.done': 'Took {duration}', 'output.gap': '… earlier output dropped …', 'output.error': 'live output stream interrupted: {error}', 'terminal.signal': 'signal {signal}', diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx index bca39ac732..6dbb9f4519 100644 --- a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx +++ b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, } from '@deepseek-ai/dsh-api-activity-controller/client' +import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { ActivityListAction, type ActivityListActionProps } from '../src/client/ActivityListAction.tsx' import { zh } from '../src/client/locales.ts' @@ -12,6 +13,7 @@ import { zh } from '../src/client/locales.ts' afterEach(() => { cleanup() vi.restoreAllMocks() + vi.useRealTimers() }) const SESSION = 'session' as SessionId @@ -36,9 +38,21 @@ function unownedRow(over: Partial = {}): ActivityRow { return rest } +function job(over: Partial = {}): SessionJob { + return { + id: 'bash-1' as SessionJob['id'], + kind: 'bash', + label: 'pnpm run build', + status: 'running', + startedAt: 1_700_000_000_000, + ...over, + } +} + function props( snapshot: Partial, observe: ActivityListActionProps['observe'] = () => () => {}, + jobs: readonly SessionJob[] = [], ): ActivityListActionProps { const state: ActivityFeedSnapshot = { rowsBySession: {}, @@ -49,7 +63,11 @@ function props( function useActivity(select: (value: ActivityFeedSnapshot) => T): T { return select(state) } - return { sessionId: SESSION, useActivity, observe, t } as unknown as ActivityListActionProps + const sessionsState = { jobsBySession: jobs.length > 0 ? { [SESSION]: jobs } : {} } + function useSessions(select: (value: typeof sessionsState) => T): T { + return select(sessionsState) + } + return { sessionId: SESSION, useSessions, useActivity, observe, t } as unknown as ActivityListActionProps } function openList(): void { @@ -57,19 +75,19 @@ function openList(): void { } describe('ActivityListAction visibility', () => { - it('renders nothing while the session sees no activities', () => { + it('renders nothing while the session sees no tasks', () => { const { container } = render() expect(container.innerHTML).toBe('') }) - it('counts live activities on the trigger, merging owned and unowned rows', () => { + it('counts live tasks on the trigger, merging owned and unowned activity rows', () => { render() - expect(screen.getByRole('button', { name: '2 个活动进行中' })).toBeDefined() + expect(screen.getByRole('button', { name: '2 个任务进行中' })).toBeDefined() }) it('falls back to the total when nothing is live and hides foreign sessions', () => { @@ -79,7 +97,79 @@ describe('ActivityListAction visibility', () => { other: [row({ id: 'bash-9' as ActivityId, sessionId: 'other' as SessionId })], }, })} />) - expect(screen.getByRole('button', { name: '1 个活动' })).toBeDefined() + expect(screen.getByRole('button', { name: '1 个任务' })).toBeDefined() + }) +}) + +describe('ActivityListAction merged rows', () => { + it('joins a job with its correlated activity: job lifecycle, activity output', () => { + const observe = vi.fn(() => () => {}) + render() + openList() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + // One merged row: the job's label and lifecycle word, not the activity's. + expect(within(list).getAllByRole('listitem')).toHaveLength(1) + expect(within(list).getByText('pnpm run build')).toBeDefined() + expect(screen.queryByText('activity-side label')).toBeNull() + expect(within(list).getByText('winding down')).toBeDefined() + // Expansion observes the correlated activity's stream. + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(observe).toHaveBeenCalledWith('bash-act-7') + }) + + it('renders a bare job row without expansion affordances', () => { + render() + expect(screen.getByRole('button', { name: '1 个任务进行中' })).toBeDefined() + openList() + expect(screen.getByText('explore the repo')).toBeDefined() + expect(screen.queryByRole('button', { name: zh['row.expandAria'].replace('{label}', 'explore the repo') })).toBeNull() + }) + + it('keeps an activity whose job is not in the projection', () => { + render() + openList() + expect(screen.getByText('pnpm run build')).toBeDefined() + }) + + it('separates live and settled sections only when both exist', () => { + const settled = [job({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 1_700_000_012_000 })] + const { rerender } = render() + openList() + expect(screen.getByText(zh['section.settled'])).toBeDefined() + // Live-only lists carry no divider. + rerender() + expect(screen.queryByText(zh['section.settled'])).toBeNull() + }) + + it('shows a settled duration and ticks a live one', () => { + vi.useFakeTimers({ now: 1_700_000_020_000 }) + render() + openList() + expect(screen.getByText('5秒')).toBeDefined() + expect(screen.getByText('12秒')).toBeDefined() + expect(screen.getByText('1小时3分')).toBeDefined() + expect(screen.getByText('1分12秒')).toBeDefined() + act(() => { vi.advanceTimersByTime(1_000) }) + expect(screen.getByText('6秒')).toBeDefined() }) }) @@ -200,7 +290,7 @@ describe('ActivityListAction rows and observation', () => { const { rerender } = render() - expect(screen.getByRole('button', { name: '2 个活动' })).toBeDefined() + expect(screen.getByRole('button', { name: '2 个任务' })).toBeDefined() openList() const list = screen.getByRole('list', { name: zh['list.aria'] }) fireEvent.keyDown(list, { key: 'a' }) diff --git a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts b/packages/client/ui-activity/tests/browser-plugin.client.spec.ts index de6db2a069..3f84c2252d 100644 --- a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-activity/tests/browser-plugin.client.spec.ts @@ -35,6 +35,7 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) + ctx.provide('sessions', {} as never) ctx.provide('activityFeed', { state: { getSnapshot: () => ({ rowsBySession: {}, observed: {}, phase: 'pending' }), subscribe: () => () => {} }, observe: (id: string) => { observed.push(id); return () => {} }, @@ -56,7 +57,7 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType { it('declares the services it binds', () => { - expect(inject).toEqual(['activityFeed', 'slots', 'locale']) + expect(inject).toEqual(['sessions', 'activityFeed', 'slots', 'locale']) }) it('exposes the feed source and observation control through the inject face', async () => { diff --git a/packages/client/ui-activity/tsconfig.json b/packages/client/ui-activity/tsconfig.json index bbd9c5ce85..562e2c2228 100644 --- a/packages/client/ui-activity/tsconfig.json +++ b/packages/client/ui-activity/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../../api/activity-controller/tsconfig.client.json" }, + { "path": "../../api/session-controller/tsconfig.client.json" }, { "path": "../locale" }, { "path": "../ui-conversation" }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml deleted file mode 100644 index 23ebb49d68..0000000000 --- a/packages/client/ui-jobs/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/client/ui-jobs/README.md -README.md: 0523aa10ebfbaa6cde0bef391017bc0307d8bdd5 -README.zh.md: bb713f446855a9e8b23c5543827b9897f1177a48 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md deleted file mode 100644 index 0523aa10eb..0000000000 --- a/packages/client/ui-jobs/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# @deepseek-ai/dsh-client-ui-jobs - -English | [中文](README.zh.md) - -Web background-job feature owner: contributes one entry to `conversation.session.header.actions` listing the `ctx.jobs` records this session can see. The data arrives entirely through the `jobsBySession` list mirror that the [Session Controller](../../api/session-controller/README.md) folds from `session/jobs` frames, so this package issues no RPC and holds no state beyond popover visibility. - -The trigger renders only when the session has at least one job, so an ordinary conversation never grows a control for a capability it is not using. Its badge counts `running` plus `stopping` and is omitted at zero, leaving a session that holds only finished jobs a quiet entry point into its history rather than one advertising a count of nothing. The popover is a flat list: live rows first by `startedAt` ascending, then settled rows by `finishedAt` descending, with a same-millisecond tie broken on start order so the host's map iteration never decides it. A row shows the producer kind, the label, a status marker, the producer's `detail` in place of the generic status word once it has one, and an elapsed duration. That duration advances once per second while the row is live and freezes at `finishedAt`; the clock runs only while an open list holds something that moves. A settled row missing `finishedAt` reads as zero rather than as a negative figure, and a duration past an hour stays in hours rather than growing a day vocabulary no producer currently reaches. - -Settled rows stay visible and de-emphasized until the registry drops them at owner disposal. They are in the snapshot, a failed job's `detail` is the only place its failure is legible, and filtering them out here is work the output and cancellation phases would undo. A running one-shot background subagent therefore appears both here and in the [subagent catalog](../ui-subagent/README.md): the catalog navigates into the child's transcript, while this list is the only handle a future cancellation can attach to. - -Escape closes the list and returns focus to the trigger, as does a pointer press outside it. The last job disappearing closes the list before the control unmounts, so focus never vanishes from a removed node. Styling uses tokens only; copy goes through the package's own `job` locale namespace. The behavior is specified by the [Web background-job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). - -## Model Experience - -None, as this package renders host-computed registry state for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same jobs stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). - -#### KV Cache effect - -None; the package never assembles or sends provider requests. - -## Known Limitations and Deferred Work - -- **Rows are read-only** — a job's streamed output and a human-initiated cancellation are separate phases. Cancellation additionally owes a model-facing decision the seam does not answer: `kill()` marks terminal delivery reported, so an interrupt written against the current contract would leave the model believing its job is still running. -- **The list is not the registry's own set** — it shows what one session can see through the wire view, so a job owned by another session never appears here, and a process restart empties the list while the transcript keeps the `run_in_background` cards that started those jobs. An unowned job (one started without a live `Agent`) is the opposite case: it reaches every session's list, matching what `list(caller)` reports to every caller. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md deleted file mode 100644 index bb713f4468..0000000000 --- a/packages/client/ui-jobs/README.zh.md +++ /dev/null @@ -1,24 +0,0 @@ -# @deepseek-ai/dsh-client-ui-jobs - -[English](README.md) | 中文 - -Web 后台任务特性的归属方:向 `conversation.session.header.actions` 贡献一个条目,列出当前会话可见的 `ctx.jobs` 记录。数据完全来自 [Session Controller](../../api/session-controller/README.zh.md) 从 `session/jobs` 帧折叠出的 `jobsBySession` 列表镜像,因此本包不发任何 RPC,除弹层开合外不持有任何状态。 - -只有当会话至少有一个任务时才渲染触发器,普通对话不会因为一项未被使用的能力而长出控件。角标计数为 `running` 加 `stopping`,为零时省略,这样只剩已完成任务的会话保留一个安静的历史入口,而不是宣告一个「零」。弹层是一个扁平列表:活跃行在前按 `startedAt` 升序,随后终态行按 `finishedAt` 降序;毫秒相同的并列按启动顺序打破,宿主的 map 迭代顺序永远不参与决定。一行显示生产者 kind、label、状态标记、生产者一旦给出 `detail` 就取代通用状态词的那段文字,以及已耗时。该耗时在活跃时每秒推进,并在 `finishedAt` 冻结;只有当打开的列表里确实有会动的东西时时钟才运行。缺少 `finishedAt` 的终态行读作零而不是负数,超过一小时的耗时停留在小时单位,不会长出任何生产者目前都到不了的「天」词汇。 - -终态行保持可见并弱化,直到注册表在 owner 销毁时把它们丢掉。它们本就在快照里,失败任务的 `detail` 是其失败唯一可读之处,在这里过滤掉它们是输出与中断两期要推翻的工作。因此一个运行中的一次性后台 subagent 会同时出现在这里和 [subagent 目录](../ui-subagent/README.zh.md)里:目录负责进入子会话的 transcript,而这个列表是将来中断能力唯一可能附着的句柄。 - -Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理。最后一个任务消失时先关闭列表再卸载控件,焦点因此不会从一个被移除的节点上凭空消失。样式只用 token;文案走本包自己的 `job` locale 命名空间。行为由 [Web 后台任务展示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 规定。 - -## 模型体验 - -无,因为本包为人类渲染宿主计算出的注册表状态,不触及 prompt、消息、schema、流或工具结果。模型对同一批任务的视角仍属于 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 - -#### KV Cache effect - -无;本包从不组装或发送 provider 请求。 - -## 已知限制与暂缓事项 - -- **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。 -- **列表不等于注册表自己的集合** —— 它展示的是「一个会话通过线路视图能看到什么」,所以别的会话拥有的任务在这里永远不出现;而进程重启会清空列表,transcript 里启动这些任务的 `run_in_background` 卡片却还在。无主任务(在没有活体 `Agent` 时启动的)是反过来的情形:它会进入每一个会话的列表,与 `list(caller)` 对每个调用方的报告一致。 diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json deleted file mode 100644 index a195c70e08..0000000000 --- a/packages/client/ui-jobs/package.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-client-ui-jobs", - "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.1-rc.2", - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "dsh": { - "client": { - "inject": [ - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-primitives" - ], - "platform": "web" - } - }, - "scripts": { - "bundle": "tsdown", - "watch": "tsdown --watch" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/client/ui-jobs" - }, - "publishConfig": { - "access": "public" - }, - "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/client.js", - "lib/types/**/*.d.ts" - ] -} diff --git a/packages/client/ui-jobs/src/client/JobListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css deleted file mode 100644 index 2f475bd09c..0000000000 --- a/packages/client/ui-jobs/src/client/JobListAction.module.css +++ /dev/null @@ -1,125 +0,0 @@ -.root { - position: relative; -} - -.trigger { - display: inline-flex; - align-items: center; - gap: 3px; - min-height: 28px; - padding: 3px 2px; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; - cursor: pointer; -} - -.trigger:hover, -.trigger:focus-visible { - color: var(--dsw-alias-label-secondary); -} - -.trigger svg { - transition: transform 120ms ease; -} - -.triggerOpen { - transform: rotate(180deg); -} - -.triggerDot { - flex: none; -} - -.count { - margin: 0 5px; -} - -.menu { - position: absolute; - top: calc(100% + 5px); - left: 0; - z-index: 100; - box-sizing: border-box; - display: flex; - flex-direction: column; - gap: 1px; - width: 336px; - max-width: min(400px, calc(100vw - 32px)); - max-height: min(420px, calc(100vh - 140px)); - margin: 0; - padding: 4px; - overflow: auto; - list-style: none; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; - background: var(--dsw-specific-menu); - --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); - --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - box-shadow: var(--dsw-shadow-lv3); -} - -.row { - display: flex; - align-items: center; - gap: 8px; - box-sizing: border-box; - width: 100%; - min-height: 32px; - padding: 6px 8px; - border-radius: 8px; - color: var(--dsw-alias-label-primary); - font-size: 13px; - line-height: 18px; -} - -.rowSettled { - color: var(--dsw-alias-label-tertiary); -} - -.rowDot { - flex: none; -} - -.kind { - flex: none; - padding: 0 6px; - border-radius: 5px; - background: var(--dsw-alias-fill-l2); - color: var(--dsw-alias-label-secondary); - font-size: 11px; - line-height: 18px; -} - -.label { - flex: 1; - min-width: 0; - overflow: hidden; - font-family: var(--dsw-font-mono); - white-space: nowrap; - text-overflow: ellipsis; -} - -.status, -.duration { - flex: none; - color: var(--dsw-alias-label-tertiary); - font-size: 11px; - line-height: 18px; -} - -/* A failed job's detail is the producer's raw error text, so it has no bound; - without this it widens the row past the menu instead of eliding like .label. */ -.status { - max-width: 40%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.duration { - font-variant-numeric: tabular-nums; -} diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx deleted file mode 100644 index bf15074079..0000000000 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' -import { IconChevronDownOutline14, StateDot, useDismissOnOutsidePointer, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' -import { NS } from './locales.ts' -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import css from './JobListAction.module.css' - -/** Full props for the session-header background-job action. */ -export type JobListActionProps = - PropsRuntime<'conversation.session.header.actions'> & PropsLocale - -/** Stable empty list so a session with no jobs keeps one array identity. */ -const NO_TASKS: readonly JobView[] = [] - -/** A job the registry still holds open, and whose duration therefore ticks. */ -function isLive(job: JobView): boolean { - return job.status === 'running' || job.status === 'stopping' -} - -/** Closed-union exhaustiveness fence for the wire status set. */ -/* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ -function assertNever(value: never): never { - throw new Error(`unhandled job status: ${JSON.stringify(value)}`) -} - -/** - * Status marker semantics. `stopping` and `killed` share the attention color: - * both mean the work ended (or is ending) on request rather than on its own. - */ -function dotState(status: JobView['status']): StateDotState { - switch (status) { - case 'running': return 'ongoing' - case 'stopping': return 'warning' - case 'completed': return 'done' - case 'killed': return 'warning' - case 'failed': return 'error' - /* v8 ignore next -- closed wire status union */ - default: return assertNever(status) - } -} - -/** Human status word for the row and its accessible name. */ -function statusLabel(status: JobView['status'], t: TranslateNS): string { - switch (status) { - case 'running': return t('status.running') - case 'stopping': return t('status.stopping') - case 'completed': return t('status.completed') - case 'killed': return t('status.killed') - case 'failed': return t('status.failed') - /* v8 ignore next -- closed wire status union */ - default: return assertNever(status) - } -} - -/** - * Elapsed time in at most two adjacent units. A background job that outlives - * an hour is already exceptional, so hours is the widest unit — beyond that the - * figure stays in hours rather than growing a day/month vocabulary no producer - * currently reaches. - */ -function formatDuration(elapsedMs: number, t: TranslateNS): string { - const total = Math.max(0, Math.floor(elapsedMs / 1_000)) - const seconds = total % 60 - const minutes = Math.floor(total / 60) % 60 - const hours = Math.floor(total / 3_600) - if (hours > 0) return t('duration.hours', { hours, minutes }) - if (minutes > 0) return t('duration.minutes', { minutes, seconds }) - return t('duration.seconds', { seconds }) -} - -/** - * Live rows first in start order, then settled rows newest-first. Two jobs - * that settled in the same millisecond fall back to start order, so the sort - * never depends on the host's map iteration. - */ -function ordered(jobs: readonly JobView[]): JobView[] { - return [...jobs].sort((left, right) => { - const liveLeft = isLive(left) - if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 - if (liveLeft) return left.startedAt - right.startedAt - const finished = (right.finishedAt ?? right.startedAt) - (left.finishedAt ?? left.startedAt) - return finished !== 0 ? finished : left.startedAt - right.startedAt - }) -} - -/** - * Session-header entry point for this session's background jobs. It renders - * nothing at all until the session has at least one job, so an ordinary - * conversation never grows a control for a capability it is not using. - * @param props - runtime slot currency plus the namespace translator. - * @returns the trigger and its popover list, or null when there is nothing to show. - */ -export function JobListAction({ sessionId, useSessions, t }: JobListActionProps) { - const jobs = useSessions(state => state.jobsBySession[sessionId]) ?? NO_TASKS - const [open, setOpen] = useState(false) - const [now, setNow] = useState(() => Date.now()) - const rootRef = useRef(null) - const triggerRef = useRef(null) - - const rows = useMemo(() => ordered(jobs), [jobs]) - const liveCount = useMemo(() => jobs.filter(isLive).length, [jobs]) - - useDismissOnOutsidePointer(rootRef, open, setOpen) - - // The clock only runs while an open list is showing something that moves. - useEffect(() => { - if (!open || liveCount === 0) return - setNow(Date.now()) - const timer = setInterval(() => { setNow(Date.now()) }, 1_000) - return () => { clearInterval(timer) } - }, [open, liveCount]) - - // The last job disappearing removes this control; close first so focus does - // not vanish from an unmounting node. - useEffect(() => { - if (jobs.length === 0 && open) setOpen(false) - }, [jobs.length, open]) - - if (jobs.length === 0) return null - - const countKey = liveCount > 0 - ? (liveCount === 1 ? 'count.live.one' : 'count.live.other') - : (jobs.length === 1 ? 'count.idle.one' : 'count.idle.other') - const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : jobs.length }) - - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Escape' || !open) return - event.preventDefault() - setOpen(false) - triggerRef.current?.focus() - } - - return ( -
        - - {open - ? ( -
          - {rows.map((job) => { - const live = isLive(job) - const elapsed = live ? now - job.startedAt : (job.finishedAt ?? job.startedAt) - job.startedAt - const duration = formatDuration(elapsed, t) - const status = statusLabel(job.status, t) - return ( -
        • - - {job.kind} - {job.label} - {job.detail ?? status} - - {duration} - -
        • - ) - })} -
        - ) - : null} -
        - ) -} diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts deleted file mode 100644 index 22dc2c8425..0000000000 --- a/packages/client/ui-jobs/src/client/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Background-job plugin, browser half: contributes one session-header action - * that renders this session's `ctx.jobs` records. The data arrives entirely - * through the `jobsBySession` list mirror, so the plugin issues no RPC and - * holds no state of its own beyond popover visibility. - */ -import type { Context as ClientContext } from '@deepseek-ai/cordis' -import { JobListAction } from './JobListAction.tsx' -import type {} from '@deepseek-ai/dsh-client-locale/client' -import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' -import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import { en, NS, zh, type JobKey } from './locales.ts' - -declare module '@deepseek-ai/dsh-client-ui-slots' { - interface LocaleNamespaceMap { - /** Background-job list copy. */ - 'job': JobKey - } -} - -export type { JobListActionProps } from './JobListAction.tsx' - -/** Required services for locale registration and header-slot contribution. */ -export const inject = ['sessions', 'slots', 'locale'] - -/** - * Client plugin body: register the dictionaries and the header action. - * @param ctx - client root context. - */ -export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-job: dictionaries') - ctx.slots.inject( - 'conversation.session.header.actions', - () => ctx.slots.register({ - name: 'conversation.session.header.actions', - id: 'job-list', - // After the subagent catalog: session lineage reads before process work. - order: 20, - locale: NS, - }, JobListAction), - ) -} diff --git a/packages/client/ui-jobs/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts deleted file mode 100644 index 70bb2e9e0f..0000000000 --- a/packages/client/ui-jobs/src/client/locales.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** `job` namespace dictionaries. */ - -/** Dictionary namespace owned by this plugin. */ -export const NS = 'job' - -/** Simplified Chinese dictionary (the key-set source of truth). */ -export const zh = { - 'count.live.one': '{count} 个后台任务运行中', - 'count.live.other': '{count} 个后台任务运行中', - 'count.idle.one': '{count} 个后台任务', - 'count.idle.other': '{count} 个后台任务', - 'list.aria': '后台任务', - 'status.running': '运行中', - 'status.stopping': '正在停止', - 'status.completed': '已完成', - 'status.killed': '已取消', - 'status.failed': '已失败', - 'duration.seconds': '{seconds}秒', - 'duration.minutes': '{minutes}分{seconds}秒', - 'duration.hours': '{hours}小时{minutes}分', - 'duration.title.live': '已运行 {duration}', - 'duration.title.done': '耗时 {duration}', -} as const - -/** English dictionary, key-identical to the Chinese source of truth. */ -export const en: Record = { - 'count.live.one': '{count} background job running', - 'count.live.other': '{count} background jobs running', - 'count.idle.one': '{count} background job', - 'count.idle.other': '{count} background jobs', - 'list.aria': 'Background jobs', - 'status.running': 'running', - 'status.stopping': 'stopping', - 'status.completed': 'completed', - 'status.killed': 'cancelled', - 'status.failed': 'failed', - 'duration.seconds': '{seconds}s', - 'duration.minutes': '{minutes}m {seconds}s', - 'duration.hours': '{hours}h {minutes}m', - 'duration.title.live': 'Running for {duration}', - 'duration.title.done': 'Took {duration}', -} - -/** Key domain of the `job` namespace (zh is the source of truth). */ -export type JobKey = keyof typeof zh diff --git a/packages/client/ui-jobs/src/css-modules.d.ts b/packages/client/ui-jobs/src/css-modules.d.ts deleted file mode 100644 index bc5e482353..0000000000 --- a/packages/client/ui-jobs/src/css-modules.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module '*.module.css' { - const classes: Record - export default classes -} - -declare module '*.css' diff --git a/packages/client/ui-jobs/src/index.ts b/packages/client/ui-jobs/src/index.ts deleted file mode 100644 index 94d45c3ba1..0000000000 --- a/packages/client/ui-jobs/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Background-job list plugin, node half. Pure UI plugin: the empty apply - * exists so the plugin appears in the host cordis.yml / Loader; the browser - * half ships via exports["./client"], discovered through the package.json - * dshClient declaration. - */ - -/** Host plugin body — no host-side behavior for this source plugin. */ -export function apply(): void {} diff --git a/packages/client/ui-jobs/src/invariant.ts b/packages/client/ui-jobs/src/invariant.ts deleted file mode 100644 index 0807c8600f..0000000000 --- a/packages/client/ui-jobs/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-jobs`. - * @module @deepseek-ai/dsh-client-ui-jobs/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-jobs' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-jobs-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package is a read-only projection of the - * `jobsBySession` mirror onto one header slot entry. It emits no cordis - * events, owns no cross-plugin mutable state, and its single slot registration - * proves disposal through the HMR-safety spec. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts deleted file mode 100644 index 93e6df1e5f..0000000000 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * ui-job plugin halves: the browser entry's dictionary and header-slot - * registrations against the real SlotRegistry (with fiber teardown proving - * removal — HMR safety), the inert node entry, and the invariant companion's - * ownership reservation. - */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' -import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' -import { apply, inject } from '../src/client/index.ts' -import { apply as applyNode } from '../src/index.ts' -import * as JobInvariant from '../src/invariant.ts' -import { en, NS, zh } from '../src/client/locales.ts' - -/** Slot ledger reader: entry ids currently registered in the header list. */ -function headerEntryIds(ctx: Context): (string | undefined)[] { - return ctx.slots - .entries('conversation.session.header.actions') - .map(entry => entry.options.id) -} - -/** Boot the browser half over a real slot tree that declares the header list. */ -async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { - const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - ctx.slots.register({ - name: 'root', - children: { - 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, - }, - } as never, () => null) - ctx.provide('sessions', {}) - // The locale plugin binds a settings scope, which reads the connection handle - // and the forwarded-event port. - ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) - ctx.provide('remote', { $on: () => () => {} } as never) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() - // These specs assert the shipped Chinese copy. There is no jsdom `window` in - // this lane, so browser-language detection never runs and the locale comes - // from FALLBACK_LOCALE (en): state the asserted locale explicitly. - ctx.locale.setLocale('zh') - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - return { ctx, fiber } -} - -describe('ui-job browser half', () => { - it('declares the services it binds', () => { - expect(inject).toEqual(['sessions', 'slots', 'locale']) - }) - - it('registers the header action, and fiber teardown removes it (HMR safety)', async () => { - const { ctx, fiber } = await bench() - expect(headerEntryIds(ctx)).toContain('job-list') - await fiber.dispose() - expect(headerEntryIds(ctx)).not.toContain('job-list') - }) - - it('registers both dictionaries under its own namespace and releases them with the fiber', async () => { - const { ctx, fiber } = await bench() - const translate = ctx.locale.bind(NS) - expect(translate('list.aria')).toBe(zh['list.aria']) - ctx.locale.setLocale('en') - expect(translate('list.aria')).toBe(en['list.aria']) - - // Withdrawn dictionaries leave the key unresolved rather than translated. - await fiber.dispose() - expect(translate('list.aria')).not.toBe(en['list.aria']) - }) - - it('keeps the English dictionary key-identical to the Chinese source of truth', () => { - expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) - }) -}) - -describe('ui-job node half', () => { - it('contributes no host behavior', () => { - // The node half exists only so the plugin appears in the Loader tree. - expect(applyNode).not.toThrow() - }) -}) - -describe('ui-job invariant companion', () => { - it('reserves package ownership under its declared companion name', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - const fiber = ctx.plugin(JobInvariant) - await fiber.await() - expect(JobInvariant.name).toBe('client-ui-jobs-invariant') - expect(JobInvariant.inject).toEqual(['invariants']) - // Emitting an unrelated event proves the companion installed no audit. - expect(() => { - Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event']) - }).not.toThrow() - await fiber.dispose() - }) -}) diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx deleted file mode 100644 index c3f016d58e..0000000000 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ /dev/null @@ -1,241 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' -import type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' -import { zh } from '../src/client/locales.ts' - -// Live rows render `now - startedAt`, so every assertion needs a pinned clock. -beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(START) -}) - -afterEach(() => { - cleanup() - vi.useRealTimers() - vi.restoreAllMocks() -}) - -const SESSION = 'session' as SessionId -const START = 1_700_000_000_000 -const t: JobListActionProps['t'] = makeTranslate(zh) - -function job(over: Partial = {}): JobView { - return { - id: 'bash-1' as JobView['id'], - kind: 'bash', - label: 'pnpm run build', - status: 'running', - startedAt: START, - ...over, - } -} - -function props(jobs: readonly JobView[] | undefined): JobListActionProps { - const state = { - ids: [SESSION], - byId: {}, - current: SESSION, - phase: 'ready', - subagentsByParent: {}, - jobsBySession: jobs === undefined ? {} : { [SESSION]: jobs }, - currentAddress: undefined, - } satisfies SessionListState - function useSessions(select: (snapshot: SessionListState) => T): T { - return select(state) - } - return { sessionId: SESSION, useSessions, t } as unknown as JobListActionProps -} - -/** - * Rows in render order as `[kind, label, status, duration]`. Adjacent spans - * carry no whitespace between them, so the cells are read one element at a - * time rather than split out of a flattened string. - */ -function rowCells(): string[][] { - return within(screen.getByRole('list', { name: zh['list.aria'] })) - .getAllByRole('listitem') - .map(row => [...row.children] - .map(cell => cell.textContent ?? '') - .filter(text => text !== '')) -} - -describe('JobListAction visibility', () => { - it('renders nothing while the session has no jobs', () => { - const { container } = render() - expect(container.innerHTML).toBe('') - }) - - it('counts only live jobs, and falls back to the total when none are live', () => { - const { rerender } = render() - expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() - - rerender() - expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() - }) - - it('closes and unmounts when the last job disappears while the list is open', () => { - const { container, rerender } = render() - fireEvent.click(screen.getByRole('button')) - expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() - - rerender() - expect(container.innerHTML).toBe('') - }) -}) - -describe('JobListAction rows', () => { - it('orders live jobs by start, then settled jobs newest-first', () => { - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells()).toEqual([ - ['bash', 'earlier live', '运行中', '0秒'], - ['bash', 'later live', '运行中', '0秒'], - ['bash', 'new done', '已失败', '9秒'], - ['bash', 'old done', '已完成', '1秒'], - ]) - }) - - it('breaks a settled tie on start order so map iteration never decides it', () => { - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second']) - }) - - it('prefers the producer detail over the generic status word', () => { - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells()[0]).toContain('signal: SIGTERM') - }) - - it('renders every status word, including the stopping transition', () => { - render() - fireEvent.click(screen.getByRole('button')) - const words = rowCells().map(cells => cells[2]) - expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败'])) - }) -}) - -describe('JobListAction duration', () => { - it('advances a live row once per second and freezes a settled one', () => { - vi.setSystemTime(START + 1_000) - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells()[0]).toContain('1秒') - expect(rowCells()[1]).toContain('4秒') - - act(() => { vi.advanceTimersByTime(2_000) }) - expect(rowCells()[0]).toContain('3秒') - expect(rowCells()[1]).toContain('4秒') - }) - - it('widens to minutes and then hours, and never shows a negative figure', () => { - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒']) - }) - - it('runs no clock while the list is closed', () => { - const interval = vi.spyOn(globalThis, 'setInterval') - render() - expect(interval).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button')) - expect(interval).toHaveBeenCalledTimes(1) - }) - - it('runs no clock for an open list holding only settled jobs', () => { - const interval = vi.spyOn(globalThis, 'setInterval') - render() - fireEvent.click(screen.getByRole('button')) - expect(interval).not.toHaveBeenCalled() - }) -}) - -describe('JobListAction dismissal', () => { - it('closes on Escape and returns focus to the trigger', () => { - render() - const trigger = screen.getByRole('button') - fireEvent.click(trigger) - expect(trigger.getAttribute('aria-expanded')).toBe('true') - - fireEvent.keyDown(trigger, { key: 'Escape' }) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - expect(document.activeElement).toBe(trigger) - }) - - it('ignores other keys and a closed-list Escape', () => { - render() - const trigger = screen.getByRole('button') - fireEvent.keyDown(trigger, { key: 'Escape' }) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - - fireEvent.click(trigger) - fireEvent.keyDown(trigger, { key: 'ArrowDown' }) - expect(trigger.getAttribute('aria-expanded')).toBe('true') - }) - - it('closes on an outside pointer press but not on one inside', () => { - render() - const trigger = screen.getByRole('button') - fireEvent.click(trigger) - - fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] })) - expect(trigger.getAttribute('aria-expanded')).toBe('true') - - fireEvent.pointerDown(document.body) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - }) -}) - -describe('JobListAction wire tolerance', () => { - it('treats a settled job with no finishedAt as zero-duration and sorts it by start', () => { - // `finishedAt` is optional on the wire; the Host always sets it, so this - // covers a producer or carrier that ever stops doing so. - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([ - ['finished', '3秒'], - ['no finish', '0秒'], - ]) - }) - - it('falls back to start order when neither settled job carries a finish time', () => { - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier']) - }) -}) diff --git a/packages/client/ui-jobs/tsconfig.json b/packages/client/ui-jobs/tsconfig.json deleted file mode 100644 index 727523a055..0000000000 --- a/packages/client/ui-jobs/tsconfig.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../api/session-controller/tsconfig.client.json" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../locale" - }, - { - "path": "../ui-conversation" - }, - { - "path": "../ui-primitives" - }, - { - "path": "../ui-renderer" - }, - { - "path": "../ui-session" - }, - { - "path": "../ui-slots" - }, - { - "path": "../../runtime-diagnostics/invariants" - } - ] -} diff --git a/packages/client/ui-jobs/tsdown.config.ts b/packages/client/ui-jobs/tsdown.config.ts deleted file mode 100644 index 550cd558a8..0000000000 --- a/packages/client/ui-jobs/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-ui-jobs', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 1a53958b6c..1533fe40dc 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1143,7 +1143,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [ 'client-ui-activity ActivityListAction id \'activity-list\'', 'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'', - 'client-ui-jobs JobListAction id \'job-list\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32f9a464b7..cfbccd1d51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1786,9 +1786,6 @@ importers: '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../../client/ui-input-trigger - '@deepseek-ai/dsh-client-ui-jobs': - specifier: workspace:^ - version: link:../../client/ui-jobs '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../client/ui-layout @@ -2100,6 +2097,9 @@ importers: '@deepseek-ai/dsh-api-activity-controller': specifier: workspace:^ version: link:../../api/activity-controller + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2802,45 +2802,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-jobs: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:^ - version: link:../../api/session-controller - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-renderer': - specifier: workspace:^ - version: link:../ui-renderer - '@deepseek-ai/dsh-client-ui-session': - specifier: workspace:^ - version: link:../ui-session - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-layout: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 48bc458bfa..513f801566 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -86,7 +86,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-chat': { kind: 'none', reason: 'Browser-side Chat presentation; registers nothing model-facing.' }, 'packages/client/ui-message-feedback': { kind: 'none', reason: 'Browser-side controls over the message-feedback sidecar; ratings and notes never enter the Session log, model context, or telemetry.' }, 'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' }, - 'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side read-only projection of ctx.jobs records; dsh-tool-jobs owns the model-facing behavior.' }, 'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' }, 'packages/client/ui-input-trigger': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-reference': { kind: 'indirect', reason: 'Browser-side reference selection delegates file guidance and session snapshot preparation to Host-owned providers.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 11ae967059..d92abb6ab5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -248,7 +248,6 @@ "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], - "@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"], "@deepseek-ai/dsh-client-ui-activity": ["./packages/client/ui-activity/src"], "@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"], "@deepseek-ai/dsh-client-ui-user-questions": ["./packages/client/ui-user-questions/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 41f6aea41a..037f94a822 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -77,7 +77,6 @@ { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, - { "path": "./packages/client/ui-jobs" }, { "path": "./packages/client/ui-activity" }, { "path": "./packages/client/ui-directory-picker-browse" }, { "path": "./packages/client/ui-directory-picker-native" }, From e200eadd736f22c3f532a0611d9afa99b97ca0dc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 14:23:12 +0800 Subject: [PATCH 005/141] =?UTF-8?q?fix(activity):=20acceptance=20refinemen?= =?UTF-8?q?ts=20=E2=80=94=20header=20band=20order=20and=20panel=20presenta?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive acceptance produced five presentation decisions: - Header band order is preset → tasks → subagent catalog. The root-session descendant count moves out of the breadcrumb (header.lineage) into the actions band (order 30) as SubagentCatalogAction and drops its '/' separator — a root session has no hierarchy to breadcrumb. Child sessions keep their root/child switcher unchanged. - The panel's copy control copies the command (TerminalBlock.copyText), not the output: a long command ellipsizes in the row, so the control is its only complete source, and it stays rendered before any output. - Commands and output lines wrap in full (--dsl-terminal-command-whitespace / --dsl-terminal-line-whitespace: pre-wrap), so the output region never scrolls sideways; the region caps at a fixed height and scrolls (--dsl-terminal-output-max-height) with maxLines: Infinity disabling the head/tail fold. - The panel draws no run-state dot (TerminalBlock.runStateDot: false) — the row above carries the same state — and reclaims the dot gutter via --dsl-terminal-gutter, which must target the card element itself because the variable is declared on it. The panel's left spine is gone with it. All TerminalBlock changes are opt-ins (props or --dsl-terminal-* variables); tool cards keep their shipped fold/ellipsis/dot behavior. --- .../2026-08-25-unified-task-list.i18n.yaml | 4 +- .../feature/2026-08-25-unified-task-list.md | 17 ++- .../2026-08-25-unified-task-list.zh.md | 17 ++- apps/web/tests/agent-preset-selection.e2e.ts | 5 +- .../agent-preset-selection/header.expected.md | 7 +- .../live-activity-stream/settled.expected.md | 2 +- .../streaming.expected.md | 2 +- .../src/client/ActivityListAction.module.css | 19 +++- .../src/client/ActivityListAction.tsx | 13 ++- .../activity-list-action.client.spec.tsx | 16 ++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/TerminalBlock.module.css | 19 ++-- .../ui-primitives/src/TerminalBlock.tsx | 28 +++-- .../tests/terminal-block.client.spec.tsx | 16 +++ packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 4 +- packages/client/ui-subagent/README.zh.md | 4 +- .../client/SubagentHeaderLineage.module.css | 6 -- .../src/client/SubagentHeaderLineage.tsx | 52 ++++++--- .../client/ui-subagent/src/client/index.ts | 16 ++- .../tests/browser-plugin.client.spec.ts | 10 ++ .../tests/conversation-ui.client.spec.tsx | 101 +++++++++++------- .../src/client/slot-catalog.ts | 1 + 25 files changed, 261 insertions(+), 110 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml index 031f01691a..0e65f2bce7 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-unified-task-list.md -2026-08-25-unified-task-list.md: 9ce3e6d2b066a94ee738880f69b0ddf718a10b02 -2026-08-25-unified-task-list.zh.md: ab224fd082fb8d6408449716e68e6df6c648a281 +2026-08-25-unified-task-list.md: b098ea82e4762f9528ecd71bbd2b8095c540b900 +2026-08-25-unified-task-list.zh.md: 4e5a2ef5d616f73741670c1c7ad787df19069fdd diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md index 9ce3e6d2b0..b098ea82e4 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md @@ -19,11 +19,26 @@ The service planes stay orthogonal; the join happens once, per row, in the UI pr Deliberately not joined: subagent delegation rows stay bare job rows (the subagent panel is their surface), and foreground commands stay in their tool cards — neither gains an activity producer. -## Alternatives rejected +## Alternatives considered - **Merge the services** — rejected for the cursor-semantics and model-visibility reasons above. - **Keep the job list and bolt output onto it** — leaves workflow runs (activities without jobs) homeless, forcing a second list to survive anyway. +## Refinements from browser acceptance + +Driving the merged list in the real app produced four presentation decisions, all scoped to the expanded panel and the header band: + +- **The header band order is preset → tasks → subagent catalog.** The root-session descendant count moved out of the breadcrumb (`header.lineage`) into the actions band (order 30) and dropped its `/` separator — a root session has no hierarchy to breadcrumb; child sessions keep their `root / child` switcher where the `/` is real. `SubagentCatalogAction` owns the moved seat; the lineage renderer now renders nothing on roots. +- **The panel's copy control copies the command** (`TerminalBlock.copyText`), not the output: a long command ellipsizes in the row, so the control is its only complete source, while output is selectable text below. Supplying `copyText` also keeps the control rendered before any output exists. +- **The panel scrolls instead of folding**: commands and output lines wrap in full (`--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace: pre-wrap`, so the output region never scrolls sideways) and the output region caps at a fixed height (`--dsl-terminal-output-max-height`) with `maxLines: Infinity` disabling the head/tail fold. +- **The panel draws no run-state dot** (`TerminalBlock.runStateDot: false`) — the row above it already carries the same state — and reclaims the dot gutter via `--dsl-terminal-gutter`. The panel's left spine was dropped with it. + +All four land as opt-ins on `TerminalBlock` (props or `--dsl-terminal-*` variables), so tool cards keep their shipped fold/ellipsis/dot behavior unchanged. + +## Testing + +Component suites cover the join (job lifecycle wins, activity supplies the panel, bare-job rows, standalone activities), sections, durations, and the panel options; `pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent` runs them. The keyless web e2e pair (`background-job-list`, `live-activity-stream`) replays the merged list end to end against ARIA goldens; `agent-preset-selection` pins the header band order. + ## Consequences - The two web e2e scenarios (`background-job-list`, `live-activity-stream`) now assert one "Tasks" list; the job-list scenario's row gained a live-output panel, which is the join working end to end. diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md index ab224fd082..4e5a2ef5d6 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md @@ -19,11 +19,26 @@ 刻意不合并的:subagent 委托行保持裸 job 行(subagent 面板是它的呈现面),前台命令留在工具卡片里——两者都不接活动生产者。 -## 已否决的替代方案 +## 曾考虑的替代方案 - **合并服务层**——因上述游标语义与模型可见性理由否决。 - **保留 job 列表、把输出挂上去**——workflow 运行(无 job 的 activity)无处安放,第二个列表终究得活下来。 +## 浏览器验收带来的细化 + +在真实应用里驱动合并列表得出四个呈现决策,全部限定在展开面板与头部条带: + +- **头部条带顺序为 preset → 任务 → subagent 目录。** 根会话的后代数量从面包屑(`header.lineage`)移入操作区(order 30)并去掉 `/` 分隔符——根会话没有可面包屑的层级;子会话保留 `根 / 子` 切换器,那里的 `/` 是真实层级。移位后的席位由 `SubagentCatalogAction` 拥有;谱系 renderer 在根会话上不再渲染任何东西。 +- **面板的复制控件复制命令**(`TerminalBlock.copyText`)而非输出:长命令在行内被省略号截断,控件是它唯一的完整来源,而输出本身就是下方可选中的文本。提供 `copyText` 还让控件在任何输出出现之前就保持渲染。 +- **面板以滚动取代折叠**:命令与输出行完整换行(`--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace: pre-wrap`,输出区因此永不横向滚动),输出区以固定高度封顶(`--dsl-terminal-output-max-height`),`maxLines: Infinity` 禁用头尾折叠。 +- **面板不画运行状态点**(`TerminalBlock.runStateDot: false`)——上方的行已携带同一状态——并通过 `--dsl-terminal-gutter` 收回状态点落区。面板左侧竖线随之移除。 + +四项全部以 `TerminalBlock` 的可选项落地(prop 或 `--dsl-terminal-*` 变量),工具卡片保持既有的折叠/省略/状态点行为不变。 + +## 测试 + +组件套件覆盖 join(job 生命周期优先、activity 提供面板、裸 job 行、独立 activity)、分段、时长与面板选项;`pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent` 运行它们。无密钥 web e2e 对(`background-job-list`、`live-activity-stream`)按 ARIA golden 端到端回放合并列表;`agent-preset-selection` 钉住头部条带顺序。 + ## 后果 - 两个 web e2e 场景(`background-job-list`、`live-activity-stream`)现在断言同一个"任务"列表;job 场景的行获得了实时输出面板,这正是 join 端到端生效的证据。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index df6a2baa36..c062e21367 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -280,8 +280,9 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Minimal mode') expect(snapshot).toContain('button "1 subagent"') - expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode')) - expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "Session log"')) + // The preset label leads the header band; delegation navigation follows it. + expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"')) + expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "Session log"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. expect(snapshot).not.toContain('button "Minimal mode"') diff --git a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md index 9a9f380833..612edbc213 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md @@ -1,11 +1,10 @@ - navigation "Session hierarchy": - button "Seeded turn" [disabled] - - text: / - - button "1 subagent": - - text: 1 subagent - - img - img - text: Minimal mode +- button "1 subagent": + - text: 1 subagent + - img - button "Session log": - text: Session log - img diff --git a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md index 2090dca51d..ecdcd4fea4 100644 --- a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md +++ b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md @@ -3,6 +3,6 @@ - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM {{duration}}" - img - - text: done $ printf 'streamed-%s\n' marker-line; sleep 45 + - text: $ printf 'streamed-%s\n' marker-line; sleep 45 - button "Copy" - text: streamed-marker-line diff --git a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md index ea906239aa..aa23b11410 100644 --- a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md +++ b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md @@ -3,6 +3,6 @@ - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - text: printf 'streamed-%s\n' marker-line; sleep 45 {{duration}} bash running - img - - text: running $ printf 'streamed-%s\n' marker-line; sleep 45 + - text: $ printf 'streamed-%s\n' marker-line; sleep 45 - button "Copy" - text: streamed-marker-line diff --git a/packages/client/ui-activity/src/client/ActivityListAction.module.css b/packages/client/ui-activity/src/client/ActivityListAction.module.css index d0d632ab34..93259a16d7 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.module.css +++ b/packages/client/ui-activity/src/client/ActivityListAction.module.css @@ -189,11 +189,22 @@ transform: rotate(180deg); } -/* Output hangs under its row behind a spine that ties the two together. */ .panel { - margin: 0 8px 8px 14px; - padding-left: 8px; - border-left: 2px solid var(--dsw-alias-border-l2); + margin: 0 8px 8px; + /* Long commands and output lines wrap in full instead of ellipsizing or + scrolling sideways, and the output region scrolls within a fixed height + instead of folding its middle lines. */ + --dsl-terminal-command-whitespace: pre-wrap; + --dsl-terminal-line-whitespace: pre-wrap; + --dsl-terminal-output-max-height: 288px; +} + +/* The row carries the state dot, so the panel omits its own and reclaims the + dot gutter down to an ordinary content inset. The override targets the card + element itself because the gutter variable is declared on it — a value + inherited from this container would lose to that declaration. */ +.panel :global([data-terminal]) { + --dsl-terminal-gutter: 12px; } .notice { diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-activity/src/client/ActivityListAction.tsx index ef77df0e9c..8d6c9c43f3 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.tsx +++ b/packages/client/ui-activity/src/client/ActivityListAction.tsx @@ -62,8 +62,6 @@ const NO_JOBS: readonly SessionJob[] = [] /** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */ const VIEWPORT_MARGIN = 12 -/** Height cap for one live output panel inside the popover. */ -const PANEL_MAX_LINES = 16 function isLive(row: TaskRow): boolean { return row.status === 'running' || row.status === 'stopping' @@ -136,7 +134,11 @@ function terminalLabels(t: TranslateNS): TerminalBlockLabels { noOutput: t('terminal.noOutput'), collapseAria: t('terminal.collapseAria'), collapse: t('terminal.collapse'), + // The panel never caps lines (it scrolls), so the fold controls that + // would invoke these stay unrendered. + /* v8 ignore next */ expandAria: hidden => t('terminal.expandAria', { n: hidden }), + /* v8 ignore next */ expand: hidden => t('terminal.expand', { n: hidden }), } } @@ -288,7 +290,12 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: { command={row.label} output={view.text} running={live} - maxLines={PANEL_MAX_LINES} + copyText={row.label} + // The row above the panel already carries the state dot. + runStateDot={false} + // The panel scrolls its output (a stylesheet height cap) instead + // of collapsing the middle. + maxLines={Number.POSITIVE_INFINITY} labels={labels} />
        diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx index 6dbb9f4519..878aa21c23 100644 --- a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx +++ b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx @@ -192,7 +192,9 @@ describe('ActivityListAction rows and observation', () => { expect(items[1]?.textContent).toContain('exit code: 3') }) - it('marks killed rows and expands an overflowing panel through the collapse controls', () => { + it('marks killed rows, renders every output line unfolded, and copies the command', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n') const view: ObservedActivity = { activityId: 'bash-1' as ActivityId, @@ -208,9 +210,15 @@ describe('ActivityListAction rows and observation', () => { openList() expect(screen.getByText(zh['status.killed'])).toBeDefined() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - const expand = screen.getByRole('button', { name: zh['terminal.expandAria'].replace('{n}', '8') }) - fireEvent.click(expand) - expect(screen.getByRole('button', { name: zh['terminal.collapseAria'] })).toBeDefined() + // The panel scrolls instead of folding: every line renders, no fold control. + expect(screen.getByText('line 1')).toBeDefined() + expect(screen.getByText('line 24')).toBeDefined() + expect(screen.queryByRole('button', { name: zh['terminal.expandAria'].replace('{n}', '8') })).toBeNull() + // The panel draws no state dot or label of its own — the row carries it. + expect(screen.queryByText(zh['terminal.done'])).toBeNull() + // The copy control carries the command, not the output. + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['terminal.copy'] })) }) + expect(writeText).toHaveBeenCalledWith('pnpm run build') }) it('orders live rows first by start and settled rows newest-first with tie-breaks', () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a025628aa4..c4a07f5cc1 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 4df368b270511776896c74a55b0c1d2e684035d0 -README.zh.md: 77830a1f162e963709ea4d4d25c8393e7dd97d66 +README.md: 48550de318cdd637d97c23009d95215a793c9b62 +README.zh.md: 83fdaa927c04dd130b410ac885c0d110939f48b3 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 4df368b270..48550de318 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -18,7 +18,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P ## Terminal output -`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; a running block with a supplied `output` renders that live text under the running state (an undefined output keeps the historical prompt-only frame); it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop — or the `copyText` override, which also keeps the control rendered before any output exists. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; a running block with a supplied `output` renders that live text under the running state (an undefined output keeps the historical prompt-only frame); it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. `runStateDot: false` omits the dot and its hidden label for hosts whose surrounding row already carries the same state; they reclaim the dot gutter via `--dsl-terminal-gutter`. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes; rebinding `--dsl-terminal-command-whitespace` to `pre-wrap` wraps long commands in full instead. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping (`--dsl-terminal-line-whitespace: pre-wrap` opts a host into wrapped lines with no sideways scroll), and collapses to a head slice plus a tail slice past `maxLines` (default 16; Infinity disables the fold for hosts that cap height through `--dsl-terminal-output-max-height` instead) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). ## Read rendering diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 77830a1f16..83fdaa927c 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -18,7 +18,7 @@ ## 终端输出 -`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;运行中且提供了 `output` 的块会在 running 状态下渲染这段实时文本(未提供 output 则保持历史上的仅提示行画面);由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)。 +`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件——或写入 `copyText` 覆盖值,提供它还会让控件在任何输出出现之前就保持渲染。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;运行中且提供了 `output` 的块会在 running 状态下渲染这段实时文本(未提供 output 则保持历史上的仅提示行画面);由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。`runStateDot: false` 为外围行已携带同一状态的宿主省去状态点及其隐藏标签;此类宿主通过 `--dsl-terminal-gutter` 收回状态点落区。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断;把 `--dsl-terminal-command-whitespace` 重绑为 `pre-wrap` 则让长命令完整换行。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行(`--dsl-terminal-line-whitespace: pre-wrap` 让宿主选择换行且不再横向滚动);超过 `maxLines`(默认 16;传 Infinity 可禁用折叠,供改用 `--dsl-terminal-output-max-height` 限高滚动的宿主)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)。 ## Read 渲染 diff --git a/packages/client/ui-primitives/src/TerminalBlock.module.css b/packages/client/ui-primitives/src/TerminalBlock.module.css index 7a74327c74..318df9df3a 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.module.css +++ b/packages/client/ui-primitives/src/TerminalBlock.module.css @@ -110,15 +110,18 @@ color: var(--dsw-alias-label-tertiary); } -/* `pre`, not `nowrap`: the prompt row renders the command verbatim, and - `nowrap` collapses the repeated spaces, tabs, and alignment of an indented - continuation. Both hold the single row and the ellipsis. */ +/* Default `pre`, not `nowrap`: the prompt row renders the command verbatim, + and `nowrap` would collapse the repeated spaces, tabs, and alignment of an + indented continuation. Hosts opt into wrapping long commands by rebinding + the variable to `pre-wrap`; the ellipsis then simply never engages, and + overflow-wrap is inert while white-space stays `pre`. */ .command { min-width: 0; color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; - white-space: pre; + white-space: var(--dsl-terminal-command-whitespace, pre); + overflow-wrap: anywhere; } /* Capped to the prompt's line height (Pill's own 24px height would exceed a @@ -176,10 +179,14 @@ margin: 6px; } -/* No wrapping, no word-break: alignment is the payload of terminal output. */ +/* Default `pre`: alignment is the payload of terminal output. Hosts opt + into wrapping long lines by rebinding the variable to `pre-wrap`, which + also keeps the output region from ever scrolling sideways; overflow-wrap + is inert while white-space stays `pre`. */ .line { min-height: var(--dsl-terminal-line-height); - white-space: pre; + white-space: var(--dsl-terminal-line-whitespace, pre); + overflow-wrap: anywhere; } .expand { diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index 8f87db9741..34eab4e5d0 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -61,6 +61,18 @@ export interface TerminalBlockProps { running?: boolean | undefined /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */ maxLines?: number | undefined + /** + * Copy-control payload override; the control copies the raw output when + * absent. Supplying it also keeps the control rendered before any output + * exists — a command is copyable before it prints. + */ + copyText?: string | undefined + /** + * Draw the run-state dot and its assistive label in the card gutter + * (default true). Hosts whose surrounding row already carries the same + * state omit both and reclaim the gutter via `--dsl-terminal-gutter`. + */ + runStateDot?: boolean | undefined /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined /** Localized display copy supplied by the owning render site. */ @@ -155,6 +167,8 @@ export function TerminalBlock({ signal, running = false, maxLines = DEFAULT_TERMINAL_MAX_LINES, + copyText, + runStateDot = true, className, labels, }: TerminalBlockProps) { @@ -166,7 +180,7 @@ export function TerminalBlock({ // newline (`line\n\x1b[0m`) leaves the string not ending in one while still // producing a last line with nothing visible in it. A genuinely blank final // line — the double newline — survives, since it has a real empty line before - // the terminator. The copy control still copies `text` untouched. + // the terminator. The copy control's payload is unaffected. const lines = useMemo(() => { const parsed = parseAnsiLines(text) const last = parsed[parsed.length - 1] @@ -175,9 +189,9 @@ export function TerminalBlock({ return terminated ? parsed.slice(0, -1) : parsed }, [text]) const [expanded, setExpanded] = useState(false) - // The raw output, never the rendered tree: the prompt line and the status pill - // are chrome the user did not run. - const { copied, onCopy } = useCopyFeedback(text) + // The raw output (or the caller's override), never the rendered tree: the + // prompt line and the status pill are chrome the user did not run. + const { copied, onCopy } = useCopyFeedback(copyText ?? text) const onToggle = useCallback(() => { setExpanded(value => !value) }, []) @@ -202,14 +216,14 @@ export function TerminalBlock({
        - {state.label} + {runStateDot && {state.label}} {commandLines.map((line, index) => (
        {/* One dot for the card, on the first row: the exit status the view carries is the whole call's, and bash reports no per-command status, so a dot per row would assert a per-line outcome nothing here knows. */} - {index === 0 && } + {index === 0 && runStateDot && } {/* The cwd labels the CALL, so only its first row carries it. The view knows one working directory — where the call started — and a later line may well run somewhere else (a `cd` in the @@ -224,7 +238,7 @@ export function TerminalBlock({ ))}
        {status !== undefined && {status}} - {(!running || output !== undefined) && !empty && ( + {(copyText !== undefined || ((!running || output !== undefined) && !empty)) && ( diff --git a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx index 886661b396..b4e6d7b151 100644 --- a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx @@ -98,6 +98,22 @@ describe('TerminalBlock states', () => { expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('') }) + it('copyText overrides the copy payload and keeps the control before any output', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const view = render() + // No output yet, but the command is already copyable. + const button = view.getByRole('button', { name: '复制' }) + await act(async () => { fireEvent.click(button) }) + expect(writeText).toHaveBeenCalledWith('npm run build --verbose') + }) + + it('omits the run-state dot and its assistive label when the host carries the state', () => { + const view = render() + expect(view.container.querySelector('[class*="runState"]')).toBeNull() + expect(view.queryByText('运行中')).toBeNull() + }) + it('running with supplied output streams the live text and keeps it copyable', () => { const view = render() expect(view.getByText('partial')).toBeTruthy() diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 7456c19675..3d1b41e366 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md -README.md: d4a804a94b0f2cb295fd29c09c7f32385e423495 -README.zh.md: 3cec2109cbb5f85617cf69155c5582ee43496f09 +README.md: 67240628bf91e28e3b1a226f364f2d549b28695c +README.zh.md: 1c45ce74e4c04edef22a6a59b6a041c6728cbe9a diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index d4a804a94b..67240628bf 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Web subagent feature owner: contributes current-title lineage navigation to `conversation.session.header.lineage`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.inputTriggers`. +Web subagent feature owner: contributes current-title lineage navigation to `conversation.session.header.lineage`, the root-session descendant-count entry to `conversation.session.header.actions` (ordered after the task list), reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.inputTriggers`. -The header lineage renderer reads `subagentsByParent` and session summaries through the standard `useSessions` hook. On an ordinary session it preserves the current title and, when descendants exist, appends `/` plus the descendant-count dropdown before the header action row. Every subagent breadcrumb uses the count trigger's 12px type scale and combines its title with a fixed double chevron; the current breadcrumb keeps primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, ArrowDown remains the keyboard entry path, and clicking an ancestor cancels a pending menu and navigates upward without opening it. Each catalog enables sibling switching and bolds its selected row; a catalog label overrides the session-summary title, and every switcher whose catalog has not loaded requests it automatically. Only the current subagent appends its own descendant-count dropdown. Long breadcrumb titles truncate while the chevron remains visible. The count trigger covers the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionRuntime.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. +The header lineage renderer reads `subagentsByParent` and session summaries through the standard `useSessions` hook. On an ordinary session the lineage slot stays empty; when descendants exist, the descendant-count dropdown renders in the header actions band after the task list, with no breadcrumb separator. Every subagent breadcrumb uses the count trigger's 12px type scale and combines its title with a fixed double chevron; the current breadcrumb keeps primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, ArrowDown remains the keyboard entry path, and clicking an ancestor cancels a pending menu and navigates upward without opening it. Each catalog enables sibling switching and bolds its selected row; a catalog label overrides the session-summary title, and every switcher whose catalog has not loaded requests it automatically. Only the current subagent appends its own descendant-count dropdown. Long breadcrumb titles truncate while the chevron remains visible. The count trigger covers the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionRuntime.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable and the child is not running, with copy explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer, whose input and Send action are disabled but whose independent Stop stays usable, and the takeover returns once it stops. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes prompts through `subagent.prompt`: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, while an independent Stop routes through `subagent.interrupt`. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) and the [current-turn interrupt Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md). diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 3cec2109cb..1c45ce74e4 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -Web subagent 功能 owner:向 `conversation.session.header.lineage` 贡献当前 title 谱系导航,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.inputTriggers` 的既有 `@` 引用 source。 +Web subagent 功能 owner:向 `conversation.session.header.lineage` 贡献当前 title 谱系导航,向 `conversation.session.header.actions` 贡献根会话的后代数量入口(排在任务列表之后),向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.inputTriggers` 的既有 `@` 引用 source。 -页头谱系 renderer 通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。普通会话保留当前 title,并在存在后代时,于页头操作行之前追加 `/` 和后代数量下拉菜单。每一级 subagent 面包屑都使用数量触发器的 12px 字号,并将 title 与固定显示的双向箭头组合成一个控件;当前面包屑保留主标签颜色和 500 字重,祖先面包屑则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录,ArrowDown 保留为键盘入口;点击祖先面包屑会取消待打开的菜单并仅向上导航。每个目录都可切换 sibling,并会加粗选中行;目录 label 优先于会话摘要 title,尚未加载目录的每个切换器都会自动请求该目录。只有当前 subagent 会追加自己的后代数量下拉菜单。过长的面包屑 title 会截断,而双向箭头始终保留。数量触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionRuntime.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 +页头谱系 renderer 通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。普通会话的谱系槽保持为空;存在后代时,后代数量下拉菜单渲染在页头操作区里、任务列表之后,不带任何面包屑分隔符。每一级 subagent 面包屑都使用数量触发器的 12px 字号,并将 title 与固定显示的双向箭头组合成一个控件;当前面包屑保留主标签颜色和 500 字重,祖先面包屑则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录,ArrowDown 保留为键盘入口;点击祖先面包屑会取消待打开的菜单并仅向上导航。每个目录都可切换 sibling,并会加粗选中行;目录 label 优先于会话摘要 title,尚未加载目录的每个切换器都会自动请求该目录。只有当前 subagent 会追加自己的后代数量下拉菜单。过长的面包屑 title 会截断,而双向箭头始终保留。数量触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionRuntime.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器,并以文案说明恢复路径;此类 child 仍在运行期间,selector 会让位给普通编辑器——其输入区与 Send 操作被禁用,但独立的 Stop 保持可用,停止后只读替代恢复。确切 parent 存活时,可继续 child 保留普通输入 chrome,其会话通过 `subagent.prompt` 路由提示词:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagent.interrupt` 路由。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md) 与[当前轮次中断 Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md) 规定。 diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 68b16940d9..c047a09c04 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -55,12 +55,6 @@ flex: none; } -.separator { - color: var(--dsw-alias-label-caption); - font-size: 14px; - line-height: 20px; -} - .activitySlot { display: inline-flex; flex: none; diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 2660ba3a0f..faacc43061 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -439,8 +439,6 @@ function CatalogRows({ interface CatalogDropdownSharedProps extends SubagentCatalogInjected { /** Session whose direct catalog roots the tree. */ rootSessionId: SessionId - /** Whether an ordinary title needs a breadcrumb separator before its count. */ - separator?: boolean useSessions: SubagentHeaderLineageProps['useSessions'] t: TranslateNS } @@ -482,7 +480,7 @@ function catalogMenuPosition(trigger: HTMLButtonElement): CSSProperties { /** One trigger-plus-tree dropdown over the catalog rooted at `rootSessionId`. */ function CatalogDropdown({ - rootSessionId, currentSessionId, displayTitle, openTitle, variant, separator = false, + rootSessionId, currentSessionId, displayTitle, openTitle, variant, useSessions, openChild, refresh, setCatalogOpen, t, }: CatalogDropdownProps) { const ancestorSwitcher = variant === 'switcher' && openTitle !== undefined @@ -731,7 +729,6 @@ function CatalogDropdown({ onMouseEnter={scheduleHoverOpen} onMouseLeave={scheduleHoverClose} > - {separator && /} +
        + +
        + ) +} + +describe('useDismissOnOutsidePointer', () => { + it('closes on an outside pointerdown and ignores inside ones', () => { + const setOpen = vi.fn() + const view = render() + fireEvent.pointerDown(view.getByText('inside')) + expect(setOpen).not.toHaveBeenCalled() + fireEvent.pointerDown(view.getByTestId('outside')) + expect(setOpen).toHaveBeenCalledWith(false) + }) + + it('attaches no listener while closed and detaches on close', () => { + const setOpen = vi.fn() + const view = render() + fireEvent.pointerDown(view.getByTestId('outside')) + expect(setOpen).not.toHaveBeenCalled() + + view.rerender() + view.rerender() + fireEvent.pointerDown(view.getByTestId('outside')) + expect(setOpen).not.toHaveBeenCalled() + }) + + it('ignores a pointerdown whose target is not a DOM node', () => { + const setOpen = vi.fn() + render() + const event = new Event('pointerdown', { bubbles: true }) + // Shadow the prototype getter so the listener sees a non-Node target. + Object.defineProperty(event, 'target', { get: () => ({}) }) + document.dispatchEvent(event) + expect(setOpen).not.toHaveBeenCalled() + }) +}) diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts index 66a4a906b6..3ba16a832b 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -199,6 +199,37 @@ describe('background bash observation', () => { expect(ended[0]).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) }) + it('a throwing end() is swallowed by the settlement-mapping catch', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin({ + name: 'end-throwing-activities-probe', + apply(child: Context) { + child.provide('activities', { + open: () => ({ + id: 'bash-1', + append() {}, + updateDetail() {}, + end() { throw new Error('end boom') }, + }), + }) + }, + }) + await ctx.plugin(LocalSubprocessRuntime) + ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin(ToolBash, { activityPollMs: 25 }) + await call(ctx, { command: 'echo settled', description: 'test command', run_in_background: true }) + await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity settlement mapping')) ? true : undefined) + }) + it('a throwing observation registry never breaks the background call', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/observe.spec.ts index 0152a710d8..60b7d90b82 100644 --- a/packages/shell/tool-pwsh/tests/observe.spec.ts +++ b/packages/shell/tool-pwsh/tests/observe.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -168,6 +168,39 @@ describe('background pwsh observation', () => { expect(String(row.id)).toContain('pwsh') }) + it('a throwing end() is swallowed by the settlement-mapping catch', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin({ + name: 'end-throwing-activities-probe', + apply(child: Context) { + child.provide('activities', { + open: () => ({ + id: 'pwsh-1', + append() {}, + updateDetail() {}, + end() { throw new Error('end boom') }, + }), + }) + }, + }) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakePwsh) + await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) + const pwsh = ctx.shell as FakePwsh + const warn = vi.fn() + ctx.logger.warn = warn as never + const handle = observableProcess({ stdout: { text: '' }, stderr: { text: '' } }) + pwsh.backgroundHandler = () => handle.proc + await call(ctx, { command: 'Get-Doom', description: 'test command', run_in_background: true }) + handle.finish() + await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity settlement mapping')) ? true : undefined) + }) + it('a backend without observed readers still yields a status-only process', async () => { const { ctx, pwsh } = await setup() const scripted = observableProcess() From 053979cdecbf7a5de108933e46a6048825f556cd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 21:40:44 +0800 Subject: [PATCH 010/141] test(activity): cover the owner arm and the pwsh outer observation catch The coverage lanes flag four uncovered locations after the master merge: the spec.owner spread in tool-bash/tool-pwsh observe, the workflow mirror's owner spread, and tool-pwsh's outer observation catch. The covering tests existed only on the stacked job-kill branch; give this branch its own: an owned background run per shell asserting the fenced ownerSession row, the owned workflow mirror run, and a throwing open() through the pwsh outer catch. The windows-only file-reference-local gap reproduces on master's own windows lane at the merge point and stays upstream. --- .../shell/tool-bash/tests/observe.spec.ts | 27 ++++++++ .../shell/tool-pwsh/tests/observe.spec.ts | 61 +++++++++++++++++++ .../tests/activity-mirror.spec.ts | 23 +++++++ 3 files changed, 111 insertions(+) diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts index 1a100cf833..714558227f 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -4,9 +4,11 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' @@ -262,3 +264,28 @@ describe('background bash observation', () => { expect(read.snapshot.status).toBe('completed') }) }) + +describe('owned background observation', () => { + it('mirrors an owned background run under the owning session', async () => { + const ctx = await setup() + const owner = { + id: SessionId('observe-owner'), + session: { id: SessionId('observe-owner'), header: { cwd: process.cwd() } }, + status: 'idle', + ctx, + } as unknown as Agent + ctx.agents.register(owner) + await ctx.tools.execute({ + signal: testToolSignal, + callId: ToolCallId('observe-owned-1'), + name: 'bash', + arguments: { command: 'echo owned', description: 'test command', run_in_background: true }, + agent: owner, + }) + const job = ctx.jobs.list(owner)[0] + expect(job).toBeDefined() + const row = await until(() => ctx.activities.list(owner).find(item => item.correlation?.jobId === job!.id)) + expect(row.ownerSession).toBe(owner.id) + await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined) + }) +}) diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/observe.spec.ts index 97f13b064e..c9024203fb 100644 --- a/packages/shell/tool-pwsh/tests/observe.spec.ts +++ b/packages/shell/tool-pwsh/tests/observe.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' @@ -216,3 +218,62 @@ describe('background pwsh observation', () => { expect(ctx.activities.get(row.id).outputTotal).toBe(0) }) }) + +describe('owned and degraded observation (pwsh)', () => { + it('mirrors an owned background run under the owning session', async () => { + const { ctx, pwsh } = await setup() + const owner = { + id: SessionId('pwsh-observe-owner'), + session: { id: SessionId('pwsh-observe-owner'), header: { cwd: process.cwd() } }, + status: 'idle', + ctx, + } as unknown as Agent + ctx.agents.register(owner) + const scripted = observableProcess() + pwsh.backgroundHandler = () => scripted.proc + await ctx.tools.execute({ + signal: testToolSignal, + callId: ToolCallId('pwsh-observe-owned'), + name: 'pwsh', + arguments: { command: 'Get-Slow', description: 'test command', run_in_background: true }, + agent: owner, + }) + const job = ctx.jobs.list(owner)[0] + expect(job).toBeDefined() + const row = await until(() => ctx.activities.list(owner).find(item => item.correlation?.jobId === job!.id)) + expect(row.ownerSession).toBe(owner.id) + scripted.finish() + await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined) + }) + + it('swallows a throwing observation registry and keeps the job alive', async () => { + // The FakePwsh composition with a synchronously throwing open(): the + // failure lands in the outer observation catch and the job runs on. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin({ + name: 'broken-activities-probe', + apply(child: Context) { + child.provide('activities', { open() { throw new Error('observation boom') } }) + }, + }) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakePwsh) + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) + const pwsh = ctx.shell as FakePwsh + const scripted = observableProcess() + pwsh.backgroundHandler = () => scripted.proc + await call(ctx, { command: 'Get-Slow', description: 'test command', run_in_background: true }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation unavailable')) + const job = ctx.jobs.list()[0] + expect(job?.status).toBe('running') + scripted.finish() + await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) + }) +}) diff --git a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts index 50788d453f..8f82cb2841 100644 --- a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts +++ b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import { SessionId } from '@deepseek-ai/dsh-session' import type { WorkflowRun, WorkflowRunId, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import { createWorkflowActivityMirror } from '../src/activity.ts' @@ -45,6 +48,26 @@ describe('workflow activity mirror', () => { expect(ctx.activities.get(row!.id).status).toBe('completed') }) + + it('opens an owned run under the owning agent session', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry) + const owner = { + id: SessionId('workflow-owner'), + session: { id: SessionId('workflow-owner') }, + status: 'idle', + ctx, + } as unknown as Agent + ctx.agents.register(owner) + const mirror = createWorkflowActivityMirror(ctx) + const { run } = runStub('run-owned') + mirror.start(run, owner) + // The owned row is fenced to its owner's view. + const row = ctx.activities.list(owner)[0] + expect(row?.ownerSession).toBe(owner.id) + mirror.finish(run.id, 'completed') + }) it('maps cancelled and error stops, and ends an abandoned run as killed', async () => { const ctx = new Context() await ctx.plugin(LocalActivityRegistry) From 8aa2dfa3a518844a5202092ad00587b63ea93639 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 11:40:11 +0800 Subject: [PATCH 011/141] fix(ci): exempt the activity controller from flattened client-face dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The master merge brought the published dependency policy: a package declaring dsh.client flattens its non-Cordis peers unless excluded. api-activity-controller is the same species as the excluded session and workspace controllers — a Host Remote controller whose host half relays real runtime peers (dsh-activity for the registry service and its brand constructor at the wire boundary, dsh-invariants for the companion) — so it joins CLIENT_FACE_EXCLUDE rather than dropping those peers. --- scripts/package-dependency-policy.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 63054fd183..15b481383c 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -5,6 +5,7 @@ const CLIENT_FACE_INCLUDE: readonly string[] = [] /** Packages exempted from automatic Client/Host treatment despite declaring `dsh.client`. */ const CLIENT_FACE_EXCLUDE: readonly string[] = [ + '@deepseek-ai/dsh-api-activity-controller', '@deepseek-ai/dsh-api-session-controller', '@deepseek-ai/dsh-api-workspace-controller', ] From 48939ba7426d80f9df95a8cc8ff89251634e1d5a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 11:49:00 +0800 Subject: [PATCH 012/141] test(ci): pin the activity controller in the Host relay roster The roster spec mirrors CLIENT_FACE_EXCLUDE; the activity controller joined the excluded Host relays in the previous commit. --- scripts/verify-package-dependencies.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 4294cff84b..26263fe018 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -123,6 +123,7 @@ function hostRuntimeFixture(): { describe('package dependency scope', () => { it('keeps the measured Host relay roster explicit', () => { expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([ + '@deepseek-ai/dsh-api-activity-controller', '@deepseek-ai/dsh-api-session-controller', '@deepseek-ai/dsh-api-workspace-controller', ]) From e580c0b3334f1774cd23f6d4728966bd196ccb5b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 17:45:43 +0800 Subject: [PATCH 013/141] feat(jobs): fold the streaming observation record into the job registry A job can now declare record: true and stream raw output into a bounded per-job ring through the RunningJob face its starter receives: readRecord serves any number of observers at absolute byte offsets without touching the consuming model cursor or notice state, onOutput signals advancement, and updateDetail publishes a live progress line into every snapshot. Job settlement is the record's only end: it trims retention to the settled cap and fires the final output signal, so producers no longer reconcile two terminal states. pumpJobOutput ports the offset-reader pump beside the seam. This is the first step of merging the standalone dsh-activity observation plane into jobs. --- packages/jobs/jobs-local/README.md | 13 +- packages/jobs/jobs-local/src/index.ts | 257 ++++++++++++++- packages/jobs/jobs-local/tests/jobs.spec.ts | 329 +++++++++++++++++++- packages/jobs/jobs/README.md | 13 +- packages/jobs/jobs/src/index.ts | 54 +++- packages/jobs/jobs/src/invariant.ts | 9 + packages/jobs/jobs/src/pump.ts | 96 ++++++ packages/jobs/jobs/src/types.ts | 125 +++++++- packages/jobs/jobs/tests/invariant.spec.ts | 7 + packages/jobs/jobs/tests/service.spec.ts | 103 +++++- 10 files changed, 961 insertions(+), 45 deletions(-) create mode 100644 packages/jobs/jobs/src/pump.ts diff --git a/packages/jobs/jobs-local/README.md b/packages/jobs/jobs-local/README.md index cfbda98ccf..2da0a0b339 100644 --- a/packages/jobs/jobs-local/README.md +++ b/packages/jobs/jobs-local/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh snapshots, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once; jobs die with the harness process and are not durable across restarts. +`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh snapshots, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once, and a job that declares a record keeps a bounded in-memory output ring (256 KiB live, trimmed to 16 KiB at settlement by default) that observers read at absolute offsets; jobs die with the harness process and are not durable across restarts. ## Table of Contents @@ -42,8 +42,10 @@ Loading the plugin registers `ctx.jobs`; `maxConcurrentJobsPerOwner` is optional | Field | Default | Meaning | |---|---|---| | `maxConcurrentJobsPerOwner` | `10` | Maximum `running` plus `stopping` jobs per exact owner, or in the shared unowned bucket | +| `retainBytes` | `262144` | Live record retention per job, in UTF-8 bytes | +| `settledRetainBytes` | `16384` | Record retention kept after a job settles, in UTF-8 bytes | -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-jobs-local) is the exhaustive source for the accepted field. +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-jobs-local) is the exhaustive source for the accepted fields. ### What each owner gets @@ -69,7 +71,8 @@ This section explains the design decisions behind the registry and points at the ### Design philosophy -- **In-memory records, fresh snapshots.** `LocalJobRegistry` keeps one `TrackedTask` per job and projects a new read-only snapshot per call; callers never receive live state. +- **In-memory records, fresh snapshots.** `LocalJobRegistry` keeps one `TrackedTask` per job — lifecycle state plus the optional record ring — and projects a new read-only snapshot or chunk copy per call; callers never receive live state. +- **Retention is bounded per record.** Appends past the live cap drop the oldest retained chunks (a single oversized chunk keeps its UTF-8-safe tail); a reader below the retained window gets a lossy read, never an error, and settlement trims to the settled cap. - **Owner-relative layers, one process-wide registry.** Controllers, completion listeners, and change observers are filed into the scope that registered them (`ScopedLayers`), and reads union the global layer with the owner's scope chain — so one preset's job controls never hold `start()` open for an agent whose own composition loads none, and a settlement reaches only the listeners its owner's composition registered. - **Preflight before start.** `start()` checks controller service, spec validity, live ownership, and capacity before invoking the producer, so a rejection leaves no job id or execution resource; registration commits without a later failable step. - **First-wins settlement, completion last.** The earliest terminal outcome records once, releases waiters, and notifies listeners once with per-listener containment; completion is announced after the record is committed and the visible-set change published, because a reporter may open a model turn synchronously. @@ -84,11 +87,11 @@ This section explains the design decisions behind the registry and points at the ### Scope layers -`attachController`, `onJobDone`, and `onJobsChanged` register into the calling context's scope layer. The controller question (`servesOwner`) and listener delivery (`listenersFor`, `changedFor`) walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable. +`attachController`, `onJobDone`, `onJobsChanged`, and `onOutput` register into the calling context's scope layer. The controller question (`servesOwner`) and listener delivery (`listenersFor`, `changedFor`, `outputFor`) walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable. ### Admission and settlement -`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` marks a job reported when waiters are pending, resolves every waiter, records the terminal snapshot, announces the visible-set change, then notifies completion listeners. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices; a teardown cancel marks it for the same reason — nothing will read a notice addressed to an owner being destroyed. +`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` marks a job reported when waiters are pending, trims a declared record to the settled cap, resolves every waiter, records the terminal snapshot, announces the visible-set change and the record's final output signal, then notifies completion listeners. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices; a teardown cancel marks it for the same reason — nothing will read a notice addressed to an owner being destroyed. ### Teardown diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 8c71ae1b79..15cb5336b0 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -1,7 +1,8 @@ /** * Process-local provider for the background-job capability seam - * (`ctx.jobs`). It keeps every record in memory and hands out fresh - * snapshots, never live state. + * (`ctx.jobs`). It keeps every record — lifecycle state plus the optional + * bounded output ring — in memory and hands out fresh snapshots and chunk + * copies, never live state. * * Registrations outlive producer and controller fibers. Agent or service * disposal cancels live work and awaits compliant producers; a throwing @@ -17,8 +18,8 @@ import type { ScopeLayer } from '@deepseek-ai/dsh-scope' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' import type { - JobDoneListener, JobKind, JobOutcome, JobRead, JobSnapshot, JobStart, JobStatus, - JobsChangedListener, + JobAppendOptions, JobChannel, JobDoneListener, JobKind, JobOutcome, JobOutputListener, JobRead, + JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, RunningJob, } from '@deepseek-ai/dsh-jobs' /** Timeout code that distinguishes a bounded wait from caller cancellation. */ @@ -27,6 +28,12 @@ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' /** Default maximum number of active jobs in one exact-owner bucket. */ const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10 +/** Default live record retention per job, in UTF-8 bytes. */ +const DEFAULT_RETAIN_BYTES = 256 * 1024 + +/** Default record retention kept after settlement, in UTF-8 bytes. */ +const DEFAULT_SETTLED_RETAIN_BYTES = 16 * 1024 + /** Configuration for the process-local job registry. */ export interface Config { /** @@ -34,6 +41,45 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number + /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number +} + +/** One retained record ring entry; `bytes` caches the chunk's UTF-8 length. */ +interface RingChunk { + at: number + text: string + bytes: number + channel?: JobChannel + gapBefore?: true +} + +/** The bounded output ring behind one job's declared record. */ +interface RecordState { + /** Retained chunks in offset order; offsets stay absolute across eviction. */ + chunks: RingChunk[] + /** Sum of the retained chunks' byte lengths. */ + retainedBytes: number + /** Total UTF-8 bytes ever appended. */ + total: number + /** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */ + earliest: number +} + +/** + * Producer-written state shared between the {@link RunningJob} face and the + * registered record: the starter call writes through it before the commit, + * the same object serves the job for its whole life afterwards. + */ +interface ProducerState { + /** Bounded output ring; undefined when the spec declared no record. */ + record: RecordState | undefined + /** Live progress detail until settlement replaces it with the terminal one. */ + detail: string | undefined + /** The committed registry record; undefined exactly during the starter call. */ + job: TrackedTask | undefined } /** The registry's mutable per-job record (never handed out — see {@link LocalJobRegistry.snapshot}). */ @@ -47,7 +93,8 @@ interface TrackedTask { cancel: (reason?: string) => void readOutput: (() => string) | undefined status: JobStatus - detail: string | undefined + /** Producer-shared record ring and detail line. */ + state: ProducerState output: string | undefined startedAt: number finishedAt: number | undefined @@ -67,19 +114,39 @@ function isTerminal(status: JobStatus): boolean { return status === 'completed' || status === 'killed' || status === 'failed' } +/** + * The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut + * advances past continuation bytes so the surviving text never starts inside + * a code point. + * @param text - the oversized chunk text. + * @param maxBytes - positive byte budget for the surviving tail. + * @returns the surviving tail and its exact byte length. + */ +function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } { + const raw = Buffer.from(text, 'utf8') + let start = raw.length - maxBytes + // The loop bound proves the index is in range; the assertion only + // discharges noUncheckedIndexedAccess. + while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1 + const tail = raw.subarray(start) + return { text: tail.toString('utf8'), bytes: tail.length } +} + /** * One scope's contributions: the job controllers attached from it and the - * completion listeners registered there. Both tables are anonymous because a - * contribution is identified by its own disposer, never by a name a second - * registrant could shadow. + * completion, change, and record-output listeners registered there. All + * tables are anonymous because a contribution is identified by its own + * disposer, never by a name a second registrant could shadow. */ class JobLayer implements ScopeLayer { readonly controllers = new AnonymousEntries() readonly listeners = new AnonymousEntries() readonly changed = new AnonymousEntries() + readonly output = new AnonymousEntries() isEmpty(): boolean { return this.controllers.isEmpty() && this.listeners.isEmpty() && this.changed.isEmpty() + && this.output.isEmpty() } } @@ -95,10 +162,24 @@ export class LocalJobRegistry extends JobRegistry { .min(1) .max(Number.MAX_SAFE_INTEGER) .default(DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER), + retainBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_RETAIN_BYTES), + settledRetainBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_SETTLED_RETAIN_BYTES), }) /** Schemastery-defaulted active-job limit. */ private readonly maxConcurrentJobsPerOwner: number + /** Schemastery-defaulted live record retention cap. */ + private readonly retainBytes: number + /** Schemastery-defaulted settled record retention cap. */ + private readonly settledRetainBytes: number private store = new Map() private counters = new Map() /** @@ -122,8 +203,10 @@ export class LocalJobRegistry extends JobRegistry { constructor(ctx: Context, config: Config) { super(ctx) - // Schemastery validates and fills the default before constructing the service. + // Schemastery validates and fills the defaults before constructing the service. this.maxConcurrentJobsPerOwner = (config as Required).maxConcurrentJobsPerOwner + this.retainBytes = (config as Required).retainBytes + this.settledRetainBytes = (config as Required).settledRetainBytes this.selfCtx = ctx ctx.effect(() => () => this.disposeAll(), 'jobs teardown') } @@ -147,10 +230,23 @@ export class LocalJobRegistry extends JobRegistry { ) } - const hooks = spec.run() + // The id is issued before the starter runs so the producer face can carry + // it; a throwing starter still leaves nothing registered — its ordinal is + // simply skipped. const count = (this.counters.get(spec.kind) ?? 0) + 1 this.counters.set(spec.kind, count) const id = JobId(`${spec.kind}-${count}`) + const state: ProducerState = { + record: spec.record === true ? { chunks: [], retainedBytes: 0, total: 0, earliest: 0 } : undefined, + detail: undefined, + job: undefined, + } + const handle: RunningJob = { + id, + append: (text, options) => { this.appendRecord(id, state, text, options) }, + updateDetail: (detail) => { this.updateDetail(state, detail) }, + } + const hooks = spec.run(handle) let markSettled!: () => void const settled = new Promise((resolve) => { markSettled = resolve }) @@ -163,7 +259,7 @@ export class LocalJobRegistry extends JobRegistry { cancel: hooks.cancel.bind(hooks), readOutput: hooks.readOutput?.bind(hooks), status: 'running', - detail: undefined, + state, output: undefined, startedAt: Date.now(), finishedAt: undefined, @@ -173,6 +269,10 @@ export class LocalJobRegistry extends JobRegistry { waiters: 0, waitResolvers: new Set(), } + // Binding the shared producer state is the commit: writes staged inside + // the starter are already in `state`, and every later handle call reaches + // the registered record for its terminal checks and observer signals. + state.job = job this.store.set(id, job) void hooks.done.then( @@ -294,6 +394,37 @@ export class LocalJobRegistry extends JobRegistry { ) } + readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead { + const job = this.expect(id) + this.assertAccess(job, caller) + if (!Number.isSafeInteger(from) || from < 0) { + throw new Error(`invalid record read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`) + } + const record = job.state.record + if (record === undefined) { + throw new Error(`job ${job.id} declared no output record`) + } + const chunks: JobRecordChunk[] = [] + for (const chunk of record.chunks) { + if (chunk.at + chunk.bytes <= from) continue + chunks.push({ + at: chunk.at, + text: chunk.text, + ...chunk.channel !== undefined ? { channel: chunk.channel } : {}, + ...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {}, + }) + } + return { chunks, next: record.total, lossy: from < record.earliest } + } + + onOutput(listener: JobOutputListener): () => void { + return this.layers.effect( + this.ctx, + layer => layer.output.append(listener), + { label: 'jobs.onOutput()' }, + ) + } + attachController(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) @@ -362,6 +493,7 @@ export class LocalJobRegistry extends JobRegistry { /** Project a fresh read-only snapshot from the mutable record. */ private snapshot(job: TrackedTask): JobSnapshot { const ownerSession = job.owner?.id + const record = job.state.record return { id: job.id, kind: job.kind, @@ -369,10 +501,11 @@ export class LocalJobRegistry extends JobRegistry { ...job.outputLimitBytes !== undefined ? { outputLimitBytes: job.outputLimitBytes } : {}, ...ownerSession !== undefined ? { ownerSession } : {}, status: job.status, - ...job.detail !== undefined ? { detail: job.detail } : {}, + ...job.state.detail !== undefined ? { detail: job.state.detail } : {}, startedAt: job.startedAt, ...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {}, reported: job.reported, + ...record !== undefined ? { outputTotal: record.total, outputEarliest: record.earliest } : {}, } } @@ -405,6 +538,95 @@ export class LocalJobRegistry extends JobRegistry { } } + /** The record-output observers that own `owner`'s stream signals, resolved like {@link changedFor}. */ + private *outputFor(owner?: Agent): IterableIterator { + yield* this.layers.global.output.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.output.values() + } + + /** Announce that one job's record advanced (append or settlement), with per-listener containment. */ + private notifyOutput(job: TrackedTask): void { + for (const listener of this.outputFor(job.owner)) { + try { + listener(job.id) + } catch (error: unknown) { + this.selfCtx.logger.warn(`jobs: onOutput listener threw: ${String(error)}`) + } + } + } + + /** + * Append one chunk through a producer face. A chunk against a settled job or + * without a record declaration is logged and dropped; an empty chunk is + * dropped silently. A chunk staged inside the starter call is retained and + * signals no observer — the registration commit publishes it. + */ + private appendRecord(id: JobId, state: ProducerState, text: string, options?: JobAppendOptions): void { + const job = state.job + if (job !== undefined && isTerminal(job.status)) { + this.selfCtx.logger.warn(`jobs: append to settled job ${job.id} dropped`) + return + } + const record = state.record + if (record === undefined) { + this.selfCtx.logger.warn(`jobs: append to job ${id} without a record declaration dropped`) + return + } + if (text.length === 0) return + const bytes = Buffer.byteLength(text, 'utf8') + record.chunks.push({ + at: record.total, + text, + bytes, + ...options?.channel !== undefined ? { channel: options.channel } : {}, + ...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {}, + }) + record.total += bytes + record.retainedBytes += bytes + this.trimRecord(record, this.retainBytes) + if (job !== undefined) this.notifyOutput(job) + } + + /** + * Replace the live detail line through a producer face; a write against a + * settled job is logged and dropped. A write staged inside the starter call + * seeds the registered snapshot and signals no observer. + */ + private updateDetail(state: ProducerState, detail: string): void { + const job = state.job + if (job !== undefined && isTerminal(job.status)) { + this.selfCtx.logger.warn(`jobs: detail update on settled job ${job.id} dropped`) + return + } + state.detail = detail + if (job !== undefined) this.notifyChanged(job.owner) + } + + /** + * Drop retained head chunks until the ring fits `cap`; a single oversized + * chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. Offsets + * stay absolute: `earliest` advances over everything dropped. + */ + private trimRecord(record: RecordState, cap: number): void { + while (record.retainedBytes > cap && record.chunks.length > 1) { + const dropped = record.chunks.shift() + /* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */ + if (dropped === undefined) break + record.retainedBytes -= dropped.bytes + } + const single = record.chunks.length === 1 ? record.chunks[0] : undefined + if (single !== undefined && single.bytes > cap) { + const tail = utf8Tail(single.text, cap) + single.at += single.bytes - tail.bytes + single.text = tail.text + single.bytes = tail.bytes + single.gapBefore = true + record.retainedBytes = tail.bytes + } + record.earliest = record.chunks[0]?.at ?? record.total + } + /** * Record the first terminal outcome, release waiters, then announce * completion. First-wins preserves a teardown force-failure against late @@ -416,16 +638,25 @@ export class LocalJobRegistry extends JobRegistry { private settle(job: TrackedTask, outcome: JobOutcome): void { if (isTerminal(job.status)) return job.status = outcome.status - job.detail = outcome.detail + // A terminal detail replaces the live progress line; without one the last + // progress line stays, still describing what the job was doing. + if (outcome.detail !== undefined) job.state.detail = outcome.detail job.output = outcome.output job.finishedAt = Date.now() if (job.waiters > 0) job.reported = true + // Settlement ends the record: trim to the settled cap before any observer + // reads the terminal snapshot. + const record = job.state.record + if (record !== undefined) this.trimRecord(record, this.settledRetainBytes) const snapshot = this.snapshot(job) const waitResolvers = [...job.waitResolvers] job.waitResolvers.clear() for (const resolveWait of waitResolvers) resolveWait() job.markSettled() this.notifyChanged(job.owner) + // The record stream ends with settlement; the signal follows the committed + // visible-set change and precedes completion listeners, which stay last. + if (record !== undefined) this.notifyOutput(job) if (this.listenersClosed) return for (const listener of this.listenersFor(job.owner)) { try { diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 33ea29ba68..f305c73ba6 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -6,7 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart } from '@deepseek-ai/dsh-jobs' +import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RunningJob } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local' declare module '@deepseek-ai/dsh-jobs' { @@ -55,25 +55,39 @@ async function disposeAgentScope(agent: Agent): Promise { await dispose() } -/** A controllable producer start-spec: settle its `done` on demand, record cancels. */ +/** A controllable producer start-spec: settle its `done` on demand, record cancels, expose the job face. */ function producer(overrides: Partial & JobHooks> = {}) { let settle!: (outcome: JobOutcome) => void let reject!: (error: unknown) => void const cancels: (string | undefined)[] = [] - const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides + const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, record, ...hookOverrides } = overrides const hooks: JobHooks = { cancel(reason) { cancels.push(reason) }, done: new Promise((res, rej) => { settle = res; reject = rej }), ...hookOverrides, } + let started: RunningJob | undefined const spec: JobStart = { kind, label, ...owner !== undefined ? { owner } : {}, ...outputLimitBytes !== undefined ? { outputLimitBytes } : {}, - run: () => hooks, + ...record !== undefined ? { record } : {}, + run: (job) => { + started = job + return hooks + }, + } + return { + spec, + settle, + reject, + cancels, + job(): RunningJob { + if (started === undefined) throw new Error('producer not started') + return started + }, } - return { spec, settle, reject, cancels } } async function harness(config: JobsConfig = {}) { @@ -1135,3 +1149,308 @@ describe('LocalJobRegistry teardown change notifications', () => { expect(seen).toEqual([undefined, undefined, undefined]) }) }) + +describe('LocalJobRegistry record', () => { + it('appends advance absolute offsets; record reads are non-consuming and never mark reported', async () => { + const ctx = await harness() + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('hello ') + p.job().append('world', { channel: 'stderr' }) + const first = ctx.jobs.readRecord(id, 0) + expect(first.lossy).toBe(false) + expect(first.next).toBe(11) + expect(first.chunks).toEqual([ + { at: 0, text: 'hello ' }, + { at: 6, text: 'world', channel: 'stderr' }, + ]) + // A second identical read proves nothing was consumed. + expect(ctx.jobs.readRecord(id, 0)).toEqual(first) + // Resuming from `next` yields nothing until more output arrives. + expect(ctx.jobs.readRecord(id, first.next).chunks).toEqual([]) + p.job().append('!') + expect(ctx.jobs.readRecord(id, first.next).chunks).toEqual([{ at: 11, text: '!' }]) + const snapshot = ctx.jobs.get(id) + expect(snapshot.outputTotal).toBe(12) + expect(snapshot.outputEarliest).toBe(0) + // The observer surface owns no notice state: even a terminal record read + // leaves the completion notice due. + p.settle({ status: 'completed' }) + await tick() + ctx.jobs.readRecord(id, 0) + expect(ctx.jobs.get(id).reported).toBe(false) + }) + + it('staged starter writes surface at the registration commit without observer signals', async () => { + const ctx = await harness() + const outputSignals: string[] = [] + ctx.jobs.onOutput((id) => { outputSignals.push(String(id)) }) + const changes: number[] = [] + ctx.jobs.onJobsChanged(() => { changes.push(1) }) + let settle!: (outcome: JobOutcome) => void + const id = ctx.jobs.start({ + kind: 'bash', + label: 'staged writes', + record: true, + run: (job) => { + job.append('early ', { channel: 'stdout' }) + job.updateDetail('booting') + return { + cancel() {}, + done: new Promise((resolve) => { settle = resolve }), + } + }, + }) + // Only the registration commit announced anything. + expect(outputSignals).toEqual([]) + expect(changes).toEqual([1]) + const snapshot = ctx.jobs.get(id) + expect(snapshot.detail).toBe('booting') + expect(snapshot.outputTotal).toBe(6) + expect(ctx.jobs.readRecord(id, 0).chunks).toEqual([{ at: 0, text: 'early ', channel: 'stdout' }]) + settle({ status: 'completed' }) + await tick() + }) + + it('drops an empty append without waking observers', async () => { + const ctx = await harness() + const signals: string[] = [] + ctx.jobs.onOutput((id) => { signals.push(String(id)) }) + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('') + expect(signals).toEqual([]) + expect(ctx.jobs.get(id).outputTotal).toBe(0) + }) + + it('logs and drops an append without a record declaration; updateDetail still works', async () => { + const ctx = await harness() + const warn = vi.fn() + ctx.logger.warn = warn as never + const p = producer() + const id = ctx.jobs.start(p.spec) + p.job().append('nowhere to land') + expect(warn).toHaveBeenCalledWith(expect.stringContaining(`append to job ${id} without a record declaration dropped`)) + expect(() => ctx.jobs.readRecord(id, 0)).toThrow(`job ${id} declared no output record`) + const snapshot = ctx.jobs.get(id) + expect(snapshot.outputTotal).toBeUndefined() + expect(snapshot.outputEarliest).toBeUndefined() + p.job().updateDetail('7/10 items') + expect(ctx.jobs.get(id).detail).toBe('7/10 items') + }) + + it('rejects a negative or fractional record read offset', async () => { + const ctx = await harness() + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + expect(() => ctx.jobs.readRecord(id, -1)).toThrow(/invalid record read offset/) + expect(() => ctx.jobs.readRecord(id, 0.5)).toThrow(/invalid record read offset/) + expect(() => ctx.jobs.readRecord(JobId('bash-99'), 0)).toThrow(/unknown job/) + }) + + it('fences record reads to the owning session', async () => { + const ctx = await harness() + const alice = stubAgent(ctx, 'alice') + const bob = stubAgent(ctx, 'bob') + ctx.agents.register(alice) + ctx.agents.register(bob) + const p = producer({ owner: alice, record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('secret') + expect(() => ctx.jobs.readRecord(id, 0, bob)).toThrow(/belongs to another session/) + expect(() => ctx.jobs.readRecord(id, 0)).toThrow(/belongs to another session/) + expect(ctx.jobs.readRecord(id, 0, alice).chunks[0]?.text).toBe('secret') + }) + + it('evicts whole head chunks past the live cap and flags stale readers lossy', async () => { + const ctx = await harness({ retainBytes: 8 }) + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('aaaa') + p.job().append('bbbb') + p.job().append('cccc') + const read = ctx.jobs.readRecord(id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 4, text: 'bbbb' }, { at: 8, text: 'cccc' }]) + expect(read.next).toBe(12) + const snapshot = ctx.jobs.get(id) + expect(snapshot.outputEarliest).toBe(4) + expect(snapshot.outputTotal).toBe(12) + // A reader at the retained boundary is not lossy. + expect(ctx.jobs.readRecord(id, 4).lossy).toBe(false) + }) + + it('keeps only the UTF-8-safe tail of a single oversized chunk', async () => { + const ctx = await harness({ retainBytes: 5 }) + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + // '你好' is six UTF-8 bytes; a five-byte budget must not split the second code point. + p.job().append('你好') + const read = ctx.jobs.readRecord(id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 3, text: '好', gapBefore: true }]) + const snapshot = ctx.jobs.get(id) + expect(snapshot.outputEarliest).toBe(3) + expect(snapshot.outputTotal).toBe(6) + }) + + it('an exhausted byte budget leaves an empty gap-marked tail at the total offset', async () => { + const ctx = await harness({ retainBytes: 1 }) + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + // Every byte of '好' from the cut is a continuation byte, so no code point survives. + p.job().append('好') + const read = ctx.jobs.readRecord(id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 3, text: '', gapBefore: true }]) + expect(ctx.jobs.get(id).outputEarliest).toBe(3) + }) + + it('returns the whole overlapped chunk for a foreign mid-chunk offset', async () => { + const ctx = await harness() + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('abcdef') + const read = ctx.jobs.readRecord(id, 3) + expect(read.chunks).toEqual([{ at: 0, text: 'abcdef' }]) + expect(read.lossy).toBe(false) + }) + + it('preserves a producer-reported gap marker through reads', async () => { + const ctx = await harness() + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('tail after drop', { gapBefore: true, channel: 'stdout' }) + expect(ctx.jobs.readRecord(id, 0).chunks).toEqual([ + { at: 0, text: 'tail after drop', channel: 'stdout', gapBefore: true }, + ]) + }) + + it('settlement trims to the settled cap, signals once more, and drops later writes', async () => { + const ctx = await harness({ retainBytes: 1024, settledRetainBytes: 4 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + const signals: string[] = [] + ctx.jobs.onOutput((id) => { signals.push(String(id)) }) + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('abcdefgh') + p.settle({ status: 'completed', detail: 'exit code: 0' }) + await tick() + expect(signals).toEqual([String(id), String(id)]) + const snapshot = ctx.jobs.get(id) + expect(snapshot).toMatchObject({ status: 'completed', detail: 'exit code: 0', outputTotal: 8, outputEarliest: 4 }) + const read = ctx.jobs.readRecord(id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 4, text: 'efgh', gapBefore: true }]) + // The stream ended with the settlement: a trailing flush cannot break teardown. + p.job().append('late') + p.job().updateDetail('late detail') + expect(warn.mock.calls.map(call => String(call[0]))).toEqual([ + expect.stringContaining('append to settled job'), + expect.stringContaining('detail update on settled job'), + ]) + expect(signals).toHaveLength(2) + expect(ctx.jobs.get(id).detail).toBe('exit code: 0') + }) + + it('a terminal outcome without detail keeps the last progress line', async () => { + const ctx = await harness() + const changes: number[] = [] + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + ctx.jobs.onJobsChanged(() => { changes.push(1) }) + p.job().updateDetail('3/10 agents done') + expect(changes).toHaveLength(1) + expect(ctx.jobs.get(id).detail).toBe('3/10 agents done') + p.settle({ status: 'completed' }) + await tick() + expect(ctx.jobs.get(id)).toMatchObject({ status: 'completed', detail: '3/10 agents done' }) + }) + + it('a teardown-forced settlement also ends the record', async () => { + const ctx = await harness() + const warn = vi.fn() + ctx.logger.warn = warn as never + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const p = producer({ owner, record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('partial') + const disposal = disposeAgentScope(owner) + p.settle({ status: 'killed' }) + await disposal + expect(() => ctx.jobs.readRecord(id, 0, owner)).toThrow(/unknown job/) + // The producer face outlives the row; its trailing flush is dropped, not thrown. + p.job().append('late') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('append to settled job')) + }) + + it('delivers onOutput to the global layer and to the owning scope only', async () => { + const ctx = await harness() + const mount = createScope(ctx, {}) + const otherMount = createScope(ctx, {}) + const alice = stubAgent(ctx, 'alice', scopeOf(mount.ctx)) + const bob = stubAgent(ctx, 'bob', scopeOf(otherMount.ctx)) + ctx.agents.register(alice) + ctx.agents.register(bob) + const globalSignals: string[] = [] + ctx.jobs.onOutput((id) => { globalSignals.push(String(id)) }) + const scopedSignals: string[] = [] + await mount.ctx.plugin({ + inject: ['jobs'], + apply(scopedCtx: Context) { + scopedCtx.jobs.onOutput((id) => { scopedSignals.push(String(id)) }) + }, + }) + const pa = producer({ owner: alice, record: true }) + const aliceId = ctx.jobs.start(pa.spec) + const pb = producer({ owner: bob, record: true }) + ctx.jobs.start(pb.spec) + pa.job().append('for alice observers') + pb.job().append('for bob observers') + expect(globalSignals).toHaveLength(2) + expect(scopedSignals).toEqual([String(aliceId)]) + }) + + it('contains a throwing onOutput listener without breaking the append commit', async () => { + const ctx = await harness() + const warn = vi.fn() + ctx.logger.warn = warn as never + ctx.jobs.onOutput(() => { throw new Error('output boom') }) + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + p.job().append('x') + expect(ctx.jobs.get(id).outputTotal).toBe(1) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('onOutput listener threw')) + }) + + it('unregisters onOutput listeners with their registering fiber (HMR safety)', async () => { + const ctx = await harness() + const signals: string[] = [] + const fiber = ctx.plugin({ + inject: ['jobs'], + apply(pluginCtx: Context) { + pluginCtx.jobs.onOutput((id) => { signals.push(String(id)) }) + }, + }) + await fiber + const p = producer({ record: true }) + ctx.jobs.start(p.spec) + p.job().append('a') + expect(signals).toHaveLength(1) + await fiber.dispose() + p.job().append('b') + expect(signals).toHaveLength(1) + }) + + it('hands out fresh record snapshots, never live registry state', async () => { + const ctx = await harness() + const p = producer({ record: true }) + const id = ctx.jobs.start(p.spec) + const before = ctx.jobs.get(id) + p.job().append('grow') + expect(before.outputTotal).toBe(0) + expect(ctx.jobs.get(id).outputTotal).toBe(4) + }) +}) diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index 7625c3672d..10c74af908 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. This package ships the contract only: the process-local registry lives in `dsh-jobs-local`, and the model-facing controls and completion notices live in `dsh-tool-jobs`. Load an implementation to get background jobs; without one, `ctx.jobs` does not exist and `start()` cannot run. +`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. A producer can also declare a per-job observation record: an append-only bounded output stream that any number of observers (the Web client) read at absolute byte offsets without touching the model-facing consuming cursor. This package ships the contract only: the process-local registry lives in `dsh-jobs-local`, and the model-facing controls and completion notices live in `dsh-tool-jobs`. Load an implementation to get background jobs; without one, `ctx.jobs` does not exist and `start()` cannot run. ## Table of Contents @@ -31,6 +31,8 @@ Use this package when you are composing a background-job capability or writing a A producer registers work with a kind and a one-line label; the registry returns a `-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh snapshot of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the owning agent is notified through the completion listener that `dsh-tool-jobs` turns into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing output read or completion notice stays bounded. +A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RunningJob` face its starter receives: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state. + ### The ownership boundary A job belongs to the agent session that started it: another agent cannot read or stop it. Ids such as `bash-1` are predictable, so this fence is authorization, not secrecy. A job started without an owner is open to any caller and lasts until the service is disposed. @@ -75,13 +77,14 @@ This section explains the design decisions behind the contract and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract | -| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `JobSnapshot`, listener types | +| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `JobSnapshot`, listener types | | [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, and owner fields | +| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`: poll pump copying producer offset-readers into a job record | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, owner, and record-offset fields | ### Service operations -Every operation is a thin projection over the registered jobs: `get` and `list` return non-consuming snapshots, `read` advances the single stream cursor, `kill` invokes producer cancellation before changing status, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; listeners observe terminal records and visible-set changes at owner granularity, and `attachController` scopes controller availability to its effect lifetime. Exact signatures and behavior live in the JSDoc on [`src/index.ts`](src/index.ts) and the generated [`ctx.jobs` cordis surface](../../../docs/subsystems/jobs.md). +Every operation is a thin projection over the registered jobs: `get` and `list` return non-consuming snapshots, `read` advances the single stream cursor, `readRecord` reads retained record chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; listeners observe terminal records, visible-set changes, and record advancement at owner granularity, and `attachController` scopes controller availability to its effect lifetime. Exact signatures and behavior live in the JSDoc on [`src/index.ts`](src/index.ts) and the generated [`ctx.jobs` cordis surface](../../../docs/subsystems/jobs.md). @@ -118,7 +121,7 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the contract is a poor fit. They are current package constraints, not a task backlog. - **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam. -- **Stream output has one consuming cursor** — the model owns it; independent observers use the non-consuming [`dsh-activity`](../../activity/activity/README.md) plane the producers mirror into instead. +- **Stream output has one consuming cursor** — the model owns it; independent observers read the job's declared record through the non-consuming `readRecord` instead. - **Foreground work cannot be promoted** — producers choose foreground or background before starting. diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts index 7d46dfa342..796afc0882 100644 --- a/packages/jobs/jobs/src/index.ts +++ b/packages/jobs/jobs/src/index.ts @@ -1,30 +1,42 @@ /** * The background-job Service Definition (`ctx.jobs`). It owns the contract for - * job ids, session-scoped access, lifecycle state, completion listeners, and - * owner cleanup while producers retain their execution resources. The - * process-local registry lives in `@deepseek-ai/dsh-jobs-local`. + * job ids, session-scoped access, lifecycle state, completion listeners, + * owner cleanup, and the optional per-job observation record — an append-only + * bounded output stream any number of independent observers read at absolute + * byte offsets, invisible to the model-facing consuming cursor — while + * producers retain their execution resources. The process-local registry + * lives in `@deepseek-ai/dsh-jobs-local`. * @module @deepseek-ai/dsh-jobs */ import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { - JobDoneListener, JobId, JobRead, JobSnapshot, JobStart, JobsChangedListener, + JobDoneListener, JobId, JobOutputListener, JobRead, JobRecordRead, JobSnapshot, JobStart, + JobsChangedListener, } from './types.ts' export { JobId } from './types.ts' export type { + JobAppendOptions, + JobChannel, JobDoneListener, JobHooks, JobKind, JobKindMap, JobOutcome, + JobOutputListener, JobRead, + JobRecordChunk, + JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, + RunningJob, } from './types.ts' +export { pumpJobOutput } from './pump.ts' +export type { JobPumpOptions, JobPumpRead, JobPumpSource } from './pump.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -58,6 +70,15 @@ declare module '@deepseek-ai/cordis' { * than process-wide: registrations made from an unscoped context serve * every owner, and registrations made under an agent composition's scope * serve exactly the agents composed under it. + * - Record observation never consumes. Any number of {@link readRecord} + * readers hold their own absolute byte offsets; a record read changes no + * cursor and no notice state, so the model-facing surfaces (the consuming + * {@link read}, completion notices) are unaffected. + * - Record retention is bounded. Appends past the live cap drop the oldest + * retained bytes; a reader below the retained window gets a lossy read, + * never an error. Settlement — the producer outcome, a kill, or teardown — + * trims retention to the settled cap and ends the stream; the record has no + * separate lifecycle. */ export abstract class JobRegistry extends Service { constructor(ctx: Context) { @@ -166,6 +187,31 @@ export abstract class JobRegistry extends Service { */ abstract onJobsChanged(listener: JobsChangedListener): () => void + /** + * Read retained record output from an absolute byte offset without consuming + * it. Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Never marks the job reported. Throws for an unknown or foreign job, a job + * without a {@link JobStart.record} declaration, or a negative or + * non-integer offset. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ + abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead + + /** + * Register an effect-scoped observer of record advancement — one signal per + * committed append and one at settlement, carrying only the job id. A + * consumer schedules a {@link readRecord} from its own cursor; the registry + * never pushes payloads. Delivery is owner-relative on the same terms as + * {@link onJobsChanged}. Listeners are contained and never awaited. + * @param listener - receives the id whose record advanced. + * @returns disposer that unregisters the listener. + */ + abstract onOutput(listener: JobOutputListener): () => void + /** * Attach an effect-scoped controller that can read and stop jobs. It serves the * owners its registering context's scope covers, and {@link start} refuses an diff --git a/packages/jobs/jobs/src/invariant.ts b/packages/jobs/jobs/src/invariant.ts index 5963179c00..36aca11fd8 100644 --- a/packages/jobs/jobs/src/invariant.ts +++ b/packages/jobs/jobs/src/invariant.ts @@ -40,6 +40,15 @@ function validateSnapshot(snapshot: JobSnapshot, owner: Agent | undefined, fail: if (snapshot.ownerSession !== expectedOwner) { fail(`job ${JSON.stringify(id)} ownerSession does not match its completion owner`) } + + const { outputTotal, outputEarliest } = snapshot + if ((outputTotal !== undefined) !== (outputEarliest !== undefined)) { + fail(`job ${JSON.stringify(id)} record offsets must be present together`) + } else if (outputTotal !== undefined && outputEarliest !== undefined + && (!Number.isSafeInteger(outputTotal) || !Number.isSafeInteger(outputEarliest) + || outputEarliest < 0 || outputEarliest > outputTotal)) { + fail(`job ${JSON.stringify(id)} record offsets must satisfy 0 <= outputEarliest <= outputTotal`) + } } /** Install checks over current unowned records and every terminal snapshot. */ diff --git a/packages/jobs/jobs/src/pump.ts b/packages/jobs/jobs/src/pump.ts new file mode 100644 index 0000000000..8f56b6abc6 --- /dev/null +++ b/packages/jobs/jobs/src/pump.ts @@ -0,0 +1,96 @@ +/** + * Shared poll pump for producers whose output substrate is a non-consuming + * offset reader (the subprocess `readFrom(fromByte)` family). Pure utility — + * no cordis, no timers retained past settlement. + * @module + */ + +import type { JobChannel, RunningJob } from './types.ts' + +/** One non-consuming offset read from a producer-owned stream. */ +export interface JobPumpRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean +} + +/** One producer-owned stream the pump copies into a job record. */ +export interface JobPumpSource { + /** Stream label attached to every chunk this source yields. */ + channel?: JobChannel + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, and the lossy flag. + */ + read(fromByte: number): JobPumpRead +} + +/** Cadence and settlement for one {@link pumpJobOutput} run. */ +export interface JobPumpOptions { + /** Poll interval in milliseconds; a positive finite number. */ + pollMs: number + /** + * Settles when the producer's work finished and its sources hold their + * final bytes. The pump drains once more after settlement and returns; + * a rejection is treated as settlement. + */ + done: Promise +} + +/** + * Copy producer streams into a job record at a bounded cadence: drain every + * source, sleep `pollMs` or until `done` settles, repeat, then drain one + * final time. A lossy source read appends its surviving tail with + * `gapBefore`, so the discontinuity stays visible to observers. Fold the + * returned promise into the producer's `JobHooks.done` chain so the final + * drain lands before settlement trims and closes the record. + * @param job - the running job's producer face receiving the copied chunks. + * @param sources - producer streams, each pumped at its own offset. + * @param options - poll cadence and the producer's settlement promise. + * @returns resolves after the final post-settlement drain. + */ +export async function pumpJobOutput( + job: RunningJob, + sources: readonly JobPumpSource[], + options: JobPumpOptions, +): Promise { + if (!Number.isFinite(options.pollMs) || options.pollMs <= 0) { + throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(options.pollMs)}`) + } + const states = sources.map(source => ({ source, cursor: 0 })) + const drain = (): void => { + for (const state of states) { + const { source } = state + const read = source.read(state.cursor) + state.cursor = read.nextOffset + if (read.text.length === 0) continue + if (source.channel === undefined && !read.lossy) { + job.append(read.text) + } else { + job.append(read.text, { + ...source.channel !== undefined ? { channel: source.channel } : {}, + ...read.lossy ? { gapBefore: true as const } : {}, + }) + } + } + } + let settled = false + const settlement = options.done.then(() => { settled = true }, () => { settled = true }) + while (!settled) { + drain() + await Promise.race([ + new Promise((resolve) => { + const timer = setTimeout(resolve, options.pollMs) + // The settlement branch wins the race; clear the loser so a finished + // pump holds no pending timer for up to one poll interval. + void settlement.then(() => { clearTimeout(timer); resolve() }) + }), + settlement, + ]) + } + drain() +} diff --git a/packages/jobs/jobs/src/types.ts b/packages/jobs/jobs/src/types.ts index 2842f6a3a5..b42d7815e4 100644 --- a/packages/jobs/jobs/src/types.ts +++ b/packages/jobs/jobs/src/types.ts @@ -38,6 +38,24 @@ export interface JobOutcome { output?: string } +/** + * Record-stream label. Producers without distinct streams omit it; consumers + * treat an unrecognized label like an absent one. + */ +export type JobChannel = 'stdout' | 'stderr' + +/** Options for one {@link RunningJob.append}. */ +export interface JobAppendOptions { + /** Stream label for the chunk; omitted for producers without distinct streams. */ + channel?: JobChannel + /** + * Marks that producer-side bytes between the previous chunk and this one + * were lost (a source window slid before the producer could copy it), so an + * observer can render the discontinuity instead of a silent splice. + */ + gapBefore?: true +} + /** * Producer declaration passed to {@link JobRegistry.start}. The runtime * preflights access and cleanup before invoking {@link run}; the producer owns @@ -50,7 +68,9 @@ export interface JobStart { label: string /** * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. + * output read, including controller status metadata. Independent of record + * retention: it bounds the consuming model surface, never + * {@link JobRegistry.readRecord}. */ outputLimitBytes?: number /** @@ -61,11 +81,49 @@ export interface JobStart { */ owner?: Agent /** - * Start the work after preflight and synchronously return its hooks. Called - * once; a throw leaves nothing registered, and the producer must clean up any - * partially started resources. + * Declares an observable output record beside the model-facing surfaces: + * {@link RunningJob.append} retains chunks in a bounded ring that any number + * of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + * Without the declaration `append` logs and drops. */ - run(): JobHooks + record?: true + /** + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the record append and live-detail writers. + */ + run(job: RunningJob): JobHooks +} + +/** + * Producer face of one registered job, handed to {@link JobStart.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — both methods log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. + */ +export interface RunningJob { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** + * Append one record chunk. Offsets advance by the chunk's UTF-8 byte + * length; an empty chunk is dropped without waking observers. Without a + * {@link JobStart.record} declaration the chunk is logged and dropped. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: JobAppendOptions): void + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * Works for every job, with or without a record. + * @param detail - the new detail line. + */ + updateDetail(detail: string): void } /** Hooks through which the runtime controls and observes producer work. */ @@ -79,13 +137,16 @@ export interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. + * registry record without claiming that the work stopped. Settlement also + * ends the record: fold any record-filling pump into this promise so the + * final drain lands before the registry trims and closes the stream. */ done: Promise /** * Consume output produced since the previous call. The producer formats * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. + * job has one consuming cursor. Independent of the record: this is the + * model-facing projection, {@link JobRegistry.readRecord} the observer one. */ readOutput?(): string } @@ -111,7 +172,10 @@ export interface JobSnapshot { ownerSession?: SessionId /** Current lifecycle state. */ status: JobStatus - /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ + /** + * Kind-specific status detail: live progress while the producer updates it + * through {@link RunningJob.updateDetail}, the terminal detail once settled. + */ detail?: string /** Epoch ms when the job was registered. */ startedAt: number @@ -125,6 +189,17 @@ export interface JobSnapshot { * otherwise spend a model request per teardown layer. */ reported: boolean + /** + * Total UTF-8 bytes ever appended to the record — the offset the next chunk + * starts at. Present exactly when the job declared {@link JobStart.record}. + */ + outputTotal?: number + /** + * Offset of the oldest retained record byte. Greater than zero exactly when + * retention dropped the head; a reader starting below it gets a lossy read. + * Present exactly when the job declared {@link JobStart.record}. + */ + outputEarliest?: number } /** Output and post-read state returned by {@link JobRegistry.read}. */ @@ -139,6 +214,33 @@ export interface JobRead { snapshot: JobSnapshot } +/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ +export interface JobRecordChunk { + /** Absolute offset of the chunk's first byte. */ + at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + text: string + /** Stream label, when the producer supplied one. */ + channel?: JobChannel + /** Producer-reported loss immediately before this chunk. */ + gapBefore?: true +} + +/** Result of one non-consuming {@link JobRegistry.readRecord}. */ +export interface JobRecordRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly JobRecordChunk[] + /** + * Offset to resume from — the record's current `outputTotal`. Always a + * chunk boundary: appends land whole and trimming only advances chunk + * starts, and consumers concatenate `chunks` under that assumption, so a + * provider serving partial chunks would silently duplicate text. + */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean +} + /** * Completion callback with the exact owner supplied at start, or `undefined` * for an unowned job. Returned promises are observed but not awaited. @@ -158,3 +260,10 @@ export type JobDoneListener = ( * set changed with it. */ export type JobsChangedListener = (owner: Agent | undefined) => void + +/** + * Observer of record advancement for one job: new appended output, or the + * settlement that ends the stream. Carries no payload — a consumer schedules a + * {@link JobRegistry.readRecord} from its own cursor. + */ +export type JobOutputListener = (id: JobId) => void diff --git a/packages/jobs/jobs/tests/invariant.spec.ts b/packages/jobs/jobs/tests/invariant.spec.ts index 5cbaf94b11..fa42eb6c74 100644 --- a/packages/jobs/jobs/tests/invariant.spec.ts +++ b/packages/jobs/jobs/tests/invariant.spec.ts @@ -62,6 +62,7 @@ describe('job-registry invariants', () => { const owner = { id: SessionId('owner') } as Agent expect(() => { notify({ ...BASE, id: JobId('subagent-2'), kind: 'subagent', ownerSession: owner.id }, owner) }) .not.toThrow() + expect(() => { notify({ ...BASE, outputTotal: 8, outputEarliest: 4 }) }).not.toThrow() }) it.each([ @@ -76,6 +77,12 @@ describe('job-registry invariants', () => { [{ ...BASE, finishedAt: 9 }, undefined, /no earlier than startedAt/], [{ ...BASE, finishedAt: 20.5 }, undefined, /no earlier than startedAt/], [{ ...BASE, ownerSession: SessionId('recorded') }, { id: SessionId('actual') } as Agent, /does not match its completion owner/], + [{ ...BASE, outputTotal: 8 }, undefined, /record offsets must be present together/], + [{ ...BASE, outputEarliest: 0 }, undefined, /record offsets must be present together/], + [{ ...BASE, outputTotal: 8, outputEarliest: 9 }, undefined, /0 <= outputEarliest <= outputTotal/], + [{ ...BASE, outputTotal: 8.5, outputEarliest: 0 }, undefined, /0 <= outputEarliest <= outputTotal/], + [{ ...BASE, outputTotal: 8, outputEarliest: 0.5 }, undefined, /0 <= outputEarliest <= outputTotal/], + [{ ...BASE, outputTotal: 8, outputEarliest: -1 }, undefined, /0 <= outputEarliest <= outputTotal/], ] as const)('rejects an incoherent registry snapshot', async (snapshot, owner, message) => { const notify = await setup() expect(() => { notify(snapshot, owner) }).toThrow(message) diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index 6c4f65c52f..ff2c282a27 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs' +import { JobId, JobRegistry, pumpJobOutput } from '@deepseek-ai/dsh-jobs' import type { - JobDoneListener, JobRead, JobSnapshot, JobStart, JobsChangedListener, + JobAppendOptions, JobDoneListener, JobOutputListener, JobPumpSource, JobRead, JobRecordRead, + JobSnapshot, JobStart, JobsChangedListener, RunningJob, } from '@deepseek-ai/dsh-jobs' /** @@ -24,8 +25,9 @@ class StubJobRegistry extends JobRegistry { } start(spec: JobStart): JobId { - spec.run() - return JobId(`${spec.kind}-1`) + const id = JobId(`${spec.kind}-1`) + spec.run({ id, append() {}, updateDetail() {} }) + return id } list(): JobSnapshot[] { @@ -56,6 +58,14 @@ class StubJobRegistry extends JobRegistry { return () => {} } + readRecord(_id: JobId, from: number): JobRecordRead { + return { chunks: [], next: from, lossy: false } + } + + onOutput(_listener: JobOutputListener): () => void { + return () => {} + } + attachController(_name: string): () => void { return () => {} } @@ -74,10 +84,13 @@ describe('JobRegistry seam', () => { expect(ctx.jobs.read(id).text).toBe('') expect(ctx.jobs.kill(id)).toBe('requested') await expect(ctx.jobs.wait(id, 5)).resolves.toMatchObject({ id }) + expect(ctx.jobs.readRecord(id, 0)).toEqual({ chunks: [], next: 0, lossy: false }) const detachListener = ctx.jobs.onJobDone(() => {}) detachListener() const detachChanges = ctx.jobs.onJobsChanged(() => {}) detachChanges() + const detachOutput = ctx.jobs.onOutput(() => {}) + detachOutput() detachController() }) @@ -94,3 +107,83 @@ describe('JobRegistry seam', () => { .rejects.toThrow(/abstract job registry seam; load an implementation such as @deepseek-ai\/dsh-jobs-local/) }) }) + +/** Record every append so pump behavior is observable without a registry. */ +function recordingJob(): { job: RunningJob; appends: { text: string; options?: JobAppendOptions }[] } { + const appends: { text: string; options?: JobAppendOptions }[] = [] + const job: RunningJob = { + id: JobId('bash-1'), + append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) }, + updateDetail() {}, + } + return { job, appends } +} + +/** A scripted source: each read() shifts the next scripted result. */ +function scriptedSource( + reads: { text: string; lossy?: boolean }[], + channel?: 'stdout' | 'stderr', +): { source: JobPumpSource; offsets: number[] } { + const offsets: number[] = [] + let offset = 0 + const source: JobPumpSource = { + ...channel !== undefined ? { channel } : {}, + read(fromByte) { + offsets.push(fromByte) + const next = reads.shift() ?? { text: '' } + offset += Buffer.byteLength(next.text, 'utf8') + return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } + }, + } + return { source, offsets } +} + +describe('pumpJobOutput', () => { + it('rejects a non-positive poll interval before touching any source', async () => { + const { job } = recordingJob() + await expect(pumpJobOutput(job, [], { pollMs: 0, done: Promise.resolve() })) + .rejects.toThrow(/invalid pump pollMs/) + }) + + it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => { + vi.useFakeTimers() + try { + const { job, appends } = recordingJob() + let settle!: () => void + const done = new Promise((resolve) => { settle = resolve }) + const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') + const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr') + const pump = pumpJobOutput(job, [out.source, err.source], { pollMs: 50, done }) + + await vi.advanceTimersByTimeAsync(50) + settle() + await pump + expect(appends).toEqual([ + { text: 'a', options: { channel: 'stdout' } }, + { text: 'bc', options: { channel: 'stdout' } }, + { text: 'E', options: { channel: 'stderr' } }, + ]) + // Every read resumed from the previous nextOffset, never from a shared cursor. + expect(out.offsets).toEqual([0, 1, 3]) + expect(err.offsets).toEqual([0, 0, 1]) + // Settlement cleared the raced poll timer instead of leaving it pending. + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('marks a lossy source read as a gap so observers see the discontinuity', async () => { + const { job, appends } = recordingJob() + const { source } = scriptedSource([{ text: 'tail', lossy: true }]) + await pumpJobOutput(job, [source], { pollMs: 1, done: Promise.resolve() }) + expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }]) + }) + + it('treats a rejected done as settlement and still drains the final bytes', async () => { + const { job, appends } = recordingJob() + const { source } = scriptedSource([{ text: 'last' }]) + await pumpJobOutput(job, [source], { pollMs: 1, done: Promise.reject(new Error('producer broke')) }) + expect(appends).toEqual([{ text: 'last' }]) + }) +}) From 8537b8d5bb620fab135b26d0700d845e90584bdc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 17:52:26 +0800 Subject: [PATCH 014/141] refactor(shell,workflow): produce into the job record instead of ctx.activities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bash and pwsh background runs declare record: true and pump their observed streams straight into the job through observeProcessRecord — the open/end pairing, outcome mapping, and registry-absent degradation branches are gone, and the pump's final drain folds into hooks.done so the record holds its last bytes before settlement closes it. activityPollMs becomes recordPollMs. tool-workflow drops its foreground activity mirror deliberately: a foreground run's live phase/log narration had the mirror as its only carrier, and the transcript keeps the recorded run/member lifecycle events. Live narration returns with backgrounded runs when workflow gains run_in_background. --- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/package.json | 2 - packages/shell/tool-bash/src/index.ts | 34 +-- packages/shell/tool-bash/src/observe.ts | 92 +++----- .../shell/tool-bash/tests/observe.spec.ts | 218 +++++------------- packages/shell/tool-bash/tsconfig.json | 3 - packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/package.json | 2 - packages/shell/tool-pwsh/src/index.ts | 40 +--- packages/shell/tool-pwsh/src/observe.ts | 92 +++----- .../shell/tool-pwsh/tests/observe.spec.ts | 121 +++------- packages/shell/tool-pwsh/tsconfig.json | 3 - packages/workflow/tool-workflow/README.md | 3 +- packages/workflow/tool-workflow/package.json | 3 - .../workflow/tool-workflow/src/activity.ts | 119 ---------- packages/workflow/tool-workflow/src/index.ts | 5 - .../tests/activity-mirror.spec.ts | 129 ----------- packages/workflow/tool-workflow/tsconfig.json | 3 - 18 files changed, 166 insertions(+), 707 deletions(-) delete mode 100644 packages/workflow/tool-workflow/src/activity.ts delete mode 100644 packages/workflow/tool-workflow/tests/activity-mirror.spec.ts diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 5e30ed598e..39e894970a 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -55,7 +55,7 @@ The tool executes `bash -c ` and returns the combined output. Commands ### Running long commands in the background -Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. When the optional `ctx.activities` registry is loaded, the committed background run is also mirrored into it best-effort: a `bash` activity correlated to the call and job pumps the handle's non-consuming `observed` readers at `activityPollMs` (default 150), so the Web client streams live output without touching the job's cursor; any observation failure is logged and swallowed. +Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. The background job declares an observation record: the run's non-consuming `observed` readers are pumped into it at `recordPollMs` (default 150), so the Web client streams live output without touching the job's consuming cursor; a pump failure is logged and swallowed while the job runs on. ### Sandboxed execution and escalation diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 7c143f415d..d4be992ae9 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -37,7 +37,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", @@ -59,7 +58,6 @@ "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index c79fa2c4c6..c4586d0b10 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -22,10 +22,9 @@ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandb import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' -import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' -import type {} from '@deepseek-ai/dsh-activity' +import type { ShellRunResult } from '@deepseek-ai/dsh-shell' import { processOutcome } from './background.ts' -import { observeBackgroundActivity } from './observe.ts' +import { observeProcessRecord } from './observe.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' export const name = 'tool-bash' @@ -35,14 +34,14 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ - activityPollMs?: number + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } /** Runtime configuration schema for the bash tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), - activityPollMs: z.number() + recordPollMs: z.number() .step(1) .min(1) .max(Number.MAX_SAFE_INTEGER) @@ -197,7 +196,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = { export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true - const activityPollMs = config.activityPollMs ?? 150 + const recordPollMs = config.recordPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -370,32 +369,23 @@ export function apply(ctx: Context, config: Config = {}): void { error.name = 'AbortError' throw error } - // Task preflight finishes before the starter can spawn a process. - let started: ShellProcess | undefined const id = jobs.start({ kind: 'bash', label: args.command, ...exec.agent ? { owner: exec.agent } : {}, - run: () => { + record: true, + run: (job) => { const proc = ctx.shell.start(ctx.shell.resolve(request)) - started = proc + // The pump's final drain is folded into `done` so the record holds + // its last bytes before settlement trims and closes it. + const observed = observeProcessRecord(ctx, job, proc, recordPollMs) return { cancel: () => void proc.kill(), - done: proc.done.then(() => processOutcome(proc)), + done: observed.then(() => proc.done).then(() => processOutcome(proc)), readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes), } }, }) - // The starter ran synchronously inside the committed start; mirror the - // spawned process into the optional observation registry. - if (started !== undefined) { - observeBackgroundActivity(ctx, started, { - kind: 'bash', - label: args.command, - ...exec.agent ? { owner: exec.agent } : {}, - correlation: { callId: exec.callId, jobId: id }, - }, activityPollMs, processOutcome) - } return { kind: 'background' as const, jobId: id } } const result = await ctx.shell.run(ctx.shell.resolve({ diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts index 3dc2aeeb79..2e2874f9c1 100644 --- a/packages/shell/tool-bash/src/observe.ts +++ b/packages/shell/tool-bash/src/observe.ts @@ -1,82 +1,44 @@ /** - * Best-effort process-observation tap for background bash runs: mirrors the - * spawned process's captured streams into `ctx.activities` so non-consuming - * observers (the Web client) see live output without touching the job's - * consuming `readOutput` cursor. Absent registry or absent backend offset - * readers degrade to no observation; the job path is unaffected either way. + * Record tap for background bash runs: copies the spawned process's captured + * streams into the job's observation record, so non-consuming observers (the + * Web client) see live output without touching the job's consuming + * `readOutput` cursor. Observation is strictly best-effort: absent backend + * offset readers degrade to no observation, and a pump failure is logged and + * swallowed — the job path is unaffected either way. * * @module @deepseek-ai/dsh-tool-bash/observe */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' import type { ShellProcess } from '@deepseek-ai/dsh-shell' -import { pumpActivityOutput } from '@deepseek-ai/dsh-activity' -import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity' - -/** Identity of one observed background run. */ -export interface ObserveBackgroundSpec { - /** Process kind — the producing tool's registered {@link ActivityKind}. */ - kind: ActivityKind - /** One-line label (the command). */ - label: string - /** Owning live agent, when the call has one. */ - owner?: Agent - /** Links to the starting tool call and the registered job. */ - correlation: ActivityCorrelation -} +import { pumpJobOutput } from '@deepseek-ai/dsh-jobs' +import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs' /** - * Open a process beside a committed background job and pump the handle's - * non-consuming offset readers into it until the run settles, then record the - * mapped outcome. Observation is strictly best-effort: without a - * `ctx.activities` registry this is a no-op, without backend offset readers - * the process still carries status and settlement, and ANY observation - * failure — including a rejected `open()` — is logged and swallowed, because - * the background job is already committed and running. - * @param ctx - plugin context used for the optional registry and warning logs. + * Pump the process's non-consuming offset readers into the job record until + * the run settles, then drain once more. The returned promise never rejects: + * a pump failure is logged and the record simply stops advancing, while the + * process and its job continue untouched. + * @param ctx - plugin context used for the warning log. + * @param job - the running job's producer face receiving the copied chunks. * @param proc - the started background process handle. - * @param spec - process identity and correlation for the registry row. * @param pollMs - producer-configured pump cadence in milliseconds. - * @param outcome - maps the settled handle to the process outcome. + * @returns resolves after the final drain; fold into the job's `done` chain so + * the record holds its final bytes before settlement closes it. */ -export function observeBackgroundActivity( +export function observeProcessRecord( ctx: Context, + job: RunningJob, proc: ShellProcess, - spec: ObserveBackgroundSpec, pollMs: number, - outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string }, -): void { - try { - const processes = ctx.get('activities') - if (processes === undefined) return - const handle = processes.open({ - kind: spec.kind, - label: spec.label, - ...spec.owner !== undefined ? { owner: spec.owner } : {}, - correlation: spec.correlation, +): Promise { + const sources: JobPumpSource[] = [] + const stdout = proc.observed?.stdout + if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) + const stderr = proc.observed?.stderr + if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) + return pumpJobOutput(job, sources, { pollMs, done: proc.done }) + .catch((error: unknown) => { + ctx.logger.warn(`record observation pump for ${job.id} failed: ${String(error)}`) }) - const sources: ActivityPumpSource[] = [] - const stdout = proc.observed?.stdout - if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) - const stderr = proc.observed?.stderr - if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) - void pumpActivityOutput(handle, sources, { pollMs, done: proc.done }) - .catch((error: unknown) => { - ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`) - }) - .then(async () => { - // The pump can fail while the process is still running; the outcome - // mapping requires a settled handle, and `end` is first-wins, so an - // early mapping would freeze a wrong terminal state onto the row. - // `proc.done` never rejects. - await proc.done - handle.end(outcome(proc)) - }) - .catch((error: unknown) => { - ctx.logger.warn(`activity settlement mapping for ${handle.id} failed: ${String(error)}`) - }) - } catch (error: unknown) { - ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`) - } } diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts index 714558227f..8a4ac6ae2a 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -10,18 +10,19 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import type { JobId, RunningJob } from '@deepseek-ai/dsh-jobs' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' -import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' -import type { ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import type { ShellProcess } from '@deepseek-ai/dsh-shell' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import { observeProcessRecord } from '../src/observe.ts' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' const testToolSignal = new AbortController().signal const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-observe-spec-')) -/** Job harness plus the observation registry, with a fast pump for tests. */ +/** Job harness with a fast record pump for tests. */ async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -29,12 +30,11 @@ async function setup() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) - await ctx.plugin(LocalActivityRegistry) await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - await ctx.plugin(ToolBash, { activityPollMs: 25 }) + await ctx.plugin(ToolBash, { recordPollMs: 25 }) return ctx } @@ -62,12 +62,12 @@ async function until(read: () => T | undefined, timeoutMs = 5_000): Promise chunk.text).join('') +function retainedText(ctx: Context, id: JobId, caller?: Agent): string { + return ctx.jobs.readRecord(id, 0, caller).chunks.map(chunk => chunk.text).join('') } -describe('background bash observation', () => { - it('mirrors a background run into ctx.activities with correlation, live output, and settlement', async () => { +describe('background bash record', () => { + it('streams a background run into the job record with live output and settlement', async () => { const ctx = await setup() const ack = await call(ctx, { command: 'printf "line-1\\n"; sleep 0.4; printf "line-2\\n"', @@ -77,21 +77,16 @@ describe('background bash observation', () => { expect(text(ack)).toContain('started background job') const job = ctx.jobs.list()[0] expect(job).toBeDefined() + expect(job!.outputTotal).toBeDefined() - const snapshot = await until(() => - ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) - expect(snapshot.kind).toBe('bash') - expect(snapshot.label).toContain('printf') - expect(String(snapshot.correlation?.callId)).toMatch(/^observe-call-/) + // Live output appears in the record while the command is still running. + await until(() => retainedText(ctx, job!.id).includes('line-1') ? true : undefined) + expect(ctx.jobs.get(job!.id).status).toBe('running') - // Live output appears while the command is still running. - await until(() => retainedText(ctx, snapshot.id).includes('line-1') ? true : undefined) - expect(ctx.activities.get(snapshot.id).status).toBe('running') - - // Settlement carries the mapped bash outcome and the trailing output. - await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined) - expect(ctx.activities.get(snapshot.id).detail).toBe('exit code: 0') - expect(retainedText(ctx, snapshot.id)).toContain('line-2') + // Settlement ends the record with the job; the trailing bytes are drained first. + await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) + expect(ctx.jobs.get(job!.id).detail).toBe('exit code: 0') + expect(retainedText(ctx, job!.id)).toContain('line-2') // The model-facing consuming cursor still delivers everything: observation stole nothing. const collected = await until(() => { @@ -101,15 +96,13 @@ describe('background bash observation', () => { expect(collected).toContain('line-2') }) - it('a killed background job settles its mirrored process as killed', async () => { + it('a killed background job ends its record with the killed settlement', async () => { const ctx = await setup() await call(ctx, { command: 'sleep 60', description: 'test command', run_in_background: true }) const job = ctx.jobs.list()[0] - const snapshot = await until(() => - ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) ctx.jobs.kill(job!.id, undefined, 'test cleanup') - await until(() => ctx.activities.get(snapshot.id).status === 'killed' ? true : undefined) - expect(ctx.activities.get(snapshot.id).detail).toMatch(/signal|killed before exit/) + await until(() => ctx.jobs.get(job!.id).status === 'killed' ? true : undefined) + expect(ctx.jobs.get(job!.id).detail).toMatch(/signal|killed before exit/) }) it('labels stderr chunks with their channel', async () => { @@ -120,153 +113,53 @@ describe('background bash observation', () => { run_in_background: true, }) const job = ctx.jobs.list()[0] - const snapshot = await until(() => - ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) - await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined) - const chunks = ctx.activities.read(snapshot.id, 0).chunks + await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) + const chunks = ctx.jobs.readRecord(job!.id, 0).chunks expect(chunks.find(chunk => chunk.text.includes('out-line'))?.channel).toBe('stdout') expect(chunks.find(chunk => chunk.text.includes('err-line'))?.channel).toBe('stderr') }) +}) - it('a registry whose handle rejects appends degrades to a warning and still settles', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - await ctx.plugin(ToolTasks) - const ended: unknown[] = [] - await ctx.plugin({ - name: 'appending-broken-activities-probe', - apply(child: Context) { - child.provide('activities', { - open: () => ({ - id: 'bash-1', - append() { throw new Error('append boom') }, - updateDetail() {}, - end(outcome: unknown) { ended.push(outcome) }, - }), - }) +describe('observeProcessRecord', () => { + /** A RunningJob face recording appends for pump assertions. */ + function recordingJob(): { job: RunningJob; appends: string[] } { + const appends: string[] = [] + return { + job: { + id: 'bash-1' as JobId, + append(chunk) { appends.push(chunk) }, + updateDetail() {}, }, - }) - await ctx.plugin(LocalSubprocessRuntime) - ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - const warn = vi.fn() - ctx.logger.warn = warn as never - await ctx.plugin(ToolBash, { activityPollMs: 25 }) - await call(ctx, { command: 'echo tolerated', description: 'test command', run_in_background: true }) - await until(() => ended.length > 0 ? true : undefined) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation pump')) - expect(ended[0]).toMatchObject({ status: 'completed' }) + appends, + } + } + + it('degrades to no observation when the backend exposes no offset readers', async () => { + const ctx = new Context() + const { job, appends } = recordingJob() + const proc = { done: Promise.resolve() } as unknown as ShellProcess + await observeProcessRecord(ctx, job, proc, 25) + expect(appends).toEqual([]) }) - it('a pump failure waits for process settlement before mapping the outcome', async () => { + it('contains a pump failure with a warning; the job path is unaffected', async () => { const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - await ctx.plugin(ToolTasks) - const ended: unknown[] = [] - await ctx.plugin({ - name: 'early-failing-activities-probe', - apply(child: Context) { - child.provide('activities', { - open: () => ({ - id: 'bash-1', - append() { throw new Error('append boom') }, - updateDetail() {}, - end(outcome: unknown) { ended.push(outcome) }, - }), - }) - }, - }) - await ctx.plugin(LocalSubprocessRuntime) - ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) const warn = vi.fn() ctx.logger.warn = warn as never - await ctx.plugin(ToolBash, { activityPollMs: 25 }) - // One early line makes the first pump poll append (and fail) while the - // process still has most of its sleep ahead. - await call(ctx, { command: 'printf "early\\n"; sleep 0.7', description: 'test command', run_in_background: true }) - await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity observation pump')) ? true : undefined) - // The pump already failed; the outcome must wait for real settlement - // rather than freezing a fake terminal state onto the running process. - expect(ended).toHaveLength(0) - await until(() => ended.length > 0 ? true : undefined) - expect(ended[0]).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) - }) - - it('a throwing end() is swallowed by the settlement-mapping catch', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - await ctx.plugin(ToolTasks) - await ctx.plugin({ - name: 'end-throwing-activities-probe', - apply(child: Context) { - child.provide('activities', { - open: () => ({ - id: 'bash-1', - append() {}, - updateDetail() {}, - end() { throw new Error('end boom') }, - }), - }) + const { job } = recordingJob() + const proc = { + done: new Promise((resolve) => { setTimeout(resolve, 10) }), + observed: { + stdout: { readFrom() { throw new Error('reader boom') } }, }, - }) - await ctx.plugin(LocalSubprocessRuntime) - ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - const warn = vi.fn() - ctx.logger.warn = warn as never - await ctx.plugin(ToolBash, { activityPollMs: 25 }) - await call(ctx, { command: 'echo settled', description: 'test command', run_in_background: true }) - await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity settlement mapping')) ? true : undefined) - }) - - it('a throwing observation registry never breaks the background call', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - await ctx.plugin(ToolTasks) - await ctx.plugin({ - name: 'broken-activities-probe', - apply(child: Context) { - child.provide('activities', { open() { throw new Error('observation boom') } }) - }, - }) - await ctx.plugin(LocalSubprocessRuntime) - ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - const warn = vi.fn() - ctx.logger.warn = warn as never - await ctx.plugin(ToolBash, { activityPollMs: 25 }) - const ack = await call(ctx, { command: 'echo resilient', description: 'test command', run_in_background: true }) - expect(text(ack)).toContain('started background job') - expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation unavailable')) - // The job itself still completes and reads normally. - const job = ctx.jobs.list()[0] - const read = await until(() => { - const value = ctx.jobs.read(job!.id) - return value.text.includes('resilient') ? value : undefined - }) - expect(read.snapshot.status).toBe('completed') + } as unknown as ShellProcess + await observeProcessRecord(ctx, job, proc, 5) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('record observation pump for bash-1 failed')) }) }) -describe('owned background observation', () => { - it('mirrors an owned background run under the owning session', async () => { +describe('owned background record', () => { + it('fences an owned background run record under the owning session', async () => { const ctx = await setup() const owner = { id: SessionId('observe-owner'), @@ -284,8 +177,9 @@ describe('owned background observation', () => { }) const job = ctx.jobs.list(owner)[0] expect(job).toBeDefined() - const row = await until(() => ctx.activities.list(owner).find(item => item.correlation?.jobId === job!.id)) - expect(row.ownerSession).toBe(owner.id) + expect(job!.ownerSession).toBe(owner.id) await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined) + expect(() => retainedText(ctx, job!.id)).toThrow(/belongs to another session/) + await until(() => retainedText(ctx, job!.id, owner).includes('owned') ? true : undefined) }) }) diff --git a/packages/shell/tool-bash/tsconfig.json b/packages/shell/tool-bash/tsconfig.json index 8aa66f93ff..304d983a95 100644 --- a/packages/shell/tool-bash/tsconfig.json +++ b/packages/shell/tool-bash/tsconfig.json @@ -49,9 +49,6 @@ }, { "path": "../../sandbox/sandbox-policy" - }, - { - "path": "../../activity/activity" } ] } diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index 8ea7e6f1e2..cfd6af5fe0 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-tool-pwsh` gives the agent a `pwsh` tool that runs PowerShell commands through the mounted shell executor — the Windows counterpart of `dsh-tool-bash`, mirroring it call-for-call. Each call runs in a fresh pwsh process, so no state survives; `run_in_background` turns long-running commands into background jobs. Commands are PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables, with no dialect translation. Every call runs with the managed `DSH_*` environment, and under a sandboxing executor the tool teaches and enforces the Windows-specific language-mode and named-pipe contracts. Mount it with a PowerShell executor such as `dsh-pwsh-local` and the `dsh-shell-env` plugin. When the optional `ctx.activities` registry is loaded, a committed background run is also mirrored into it best-effort as a `pwsh` activity pumped at `activityPollMs` (default 150), exactly like `dsh-tool-bash`. +`dsh-tool-pwsh` gives the agent a `pwsh` tool that runs PowerShell commands through the mounted shell executor — the Windows counterpart of `dsh-tool-bash`, mirroring it call-for-call. Each call runs in a fresh pwsh process, so no state survives; `run_in_background` turns long-running commands into background jobs. Commands are PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables, with no dialect translation. Every call runs with the managed `DSH_*` environment, and under a sandboxing executor the tool teaches and enforces the Windows-specific language-mode and named-pipe contracts. Mount it with a PowerShell executor such as `dsh-pwsh-local` and the `dsh-shell-env` plugin. A background run's job declares an observation record pumped at `recordPollMs` (default 150), exactly like `dsh-tool-bash`. ## Table of Contents diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index b95e102218..49c3942a30 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -37,7 +37,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", @@ -59,7 +58,6 @@ "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index 66c29b604f..a7926e1b34 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -32,11 +32,10 @@ import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' -import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' +import type { ShellRunResult } from '@deepseek-ai/dsh-shell' import { parseExitStatus } from '@deepseek-ai/dsh-shell' -import type {} from '@deepseek-ai/dsh-activity' import { processOutcome } from './background.ts' -import { observeBackgroundActivity } from './observe.ts' +import { observeProcessRecord } from './observe.ts' import { renderPwshProcessRead, renderPwshResult } from './render.ts' import type { RenderablePwshResult } from './render.ts' @@ -46,12 +45,6 @@ declare module '@deepseek-ai/dsh-jobs' { } } -declare module '@deepseek-ai/dsh-activity' { - interface ActivityKindMap { - pwsh: 'pwsh' - } -} - export const name = 'tool-pwsh' export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] @@ -60,14 +53,14 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ - activityPollMs?: number + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } /** Runtime configuration schema for the pwsh tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), - activityPollMs: z.number() + recordPollMs: z.number() .step(1) .min(1) .max(Number.MAX_SAFE_INTEGER) @@ -210,7 +203,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = { /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */ export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true - const activityPollMs = config.activityPollMs ?? 150 + const recordPollMs = config.recordPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -394,32 +387,23 @@ export function apply(ctx: Context, config: Config = {}): void { error.name = 'AbortError' throw error } - // Task preflight finishes before the starter can spawn a process. - let started: ShellProcess | undefined const id = jobs.start({ kind: 'pwsh', label: args.command, ...exec.agent ? { owner: exec.agent } : {}, - run: () => { + record: true, + run: (job) => { const proc = ctx.shell.start(ctx.shell.resolve(request)) - started = proc + // The pump's final drain is folded into `done` so the record holds + // its last bytes before settlement trims and closes it. + const observed = observeProcessRecord(ctx, job, proc, recordPollMs) return { cancel: () => void proc.kill(), - done: proc.done.then(() => processOutcome(proc)), + done: observed.then(() => proc.done).then(() => processOutcome(proc)), readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes), } }, }) - // The starter ran synchronously inside the committed start; mirror the - // spawned process into the optional observation registry. - if (started !== undefined) { - observeBackgroundActivity(ctx, started, { - kind: 'pwsh', - label: args.command, - ...exec.agent ? { owner: exec.agent } : {}, - correlation: { callId: exec.callId, jobId: id }, - }, activityPollMs, processOutcome) - } return { kind: 'background' as const, jobId: id } } const result = await ctx.shell.run(ctx.shell.resolve({ diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts index abf325c6b9..6e9e466981 100644 --- a/packages/shell/tool-pwsh/src/observe.ts +++ b/packages/shell/tool-pwsh/src/observe.ts @@ -1,84 +1,46 @@ /** - * Best-effort process-observation tap for background pwsh runs: mirrors the - * spawned process's captured streams into `ctx.activities` so non-consuming - * observers (the Web client) see live output without touching the job's - * consuming `readOutput` cursor. Absent registry or absent backend offset - * readers degrade to no observation; the job path is unaffected either way. + * Record tap for background pwsh runs: copies the spawned process's captured + * streams into the job's observation record, so non-consuming observers (the + * Web client) see live output without touching the job's consuming + * `readOutput` cursor. Observation is strictly best-effort: absent backend + * offset readers degrade to no observation, and a pump failure is logged and + * swallowed — the job path is unaffected either way. * * @module @deepseek-ai/dsh-tool-pwsh/observe */ /* jscpd:ignore-start -- mirrors tool-bash's observe tap by design, like render/background. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' import type { ShellProcess } from '@deepseek-ai/dsh-shell' -import { pumpActivityOutput } from '@deepseek-ai/dsh-activity' -import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity' - -/** Identity of one observed background run. */ -export interface ObserveBackgroundSpec { - /** Process kind — the producing tool's registered {@link ActivityKind}. */ - kind: ActivityKind - /** One-line label (the command). */ - label: string - /** Owning live agent, when the call has one. */ - owner?: Agent - /** Links to the starting tool call and the registered job. */ - correlation: ActivityCorrelation -} +import { pumpJobOutput } from '@deepseek-ai/dsh-jobs' +import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs' /** - * Open a process beside a committed background job and pump the handle's - * non-consuming offset readers into it until the run settles, then record the - * mapped outcome. Observation is strictly best-effort: without a - * `ctx.activities` registry this is a no-op, without backend offset readers - * the process still carries status and settlement, and ANY observation - * failure — including a rejected `open()` — is logged and swallowed, because - * the background job is already committed and running. - * @param ctx - plugin context used for the optional registry and warning logs. + * Pump the process's non-consuming offset readers into the job record until + * the run settles, then drain once more. The returned promise never rejects: + * a pump failure is logged and the record simply stops advancing, while the + * process and its job continue untouched. + * @param ctx - plugin context used for the warning log. + * @param job - the running job's producer face receiving the copied chunks. * @param proc - the started background process handle. - * @param spec - process identity and correlation for the registry row. * @param pollMs - producer-configured pump cadence in milliseconds. - * @param outcome - maps the settled handle to the process outcome. + * @returns resolves after the final drain; fold into the job's `done` chain so + * the record holds its final bytes before settlement closes it. */ -export function observeBackgroundActivity( +export function observeProcessRecord( ctx: Context, + job: RunningJob, proc: ShellProcess, - spec: ObserveBackgroundSpec, pollMs: number, - outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string }, -): void { - try { - const processes = ctx.get('activities') - if (processes === undefined) return - const handle = processes.open({ - kind: spec.kind, - label: spec.label, - ...spec.owner !== undefined ? { owner: spec.owner } : {}, - correlation: spec.correlation, +): Promise { + const sources: JobPumpSource[] = [] + const stdout = proc.observed?.stdout + if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) + const stderr = proc.observed?.stderr + if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) + return pumpJobOutput(job, sources, { pollMs, done: proc.done }) + .catch((error: unknown) => { + ctx.logger.warn(`record observation pump for ${job.id} failed: ${String(error)}`) }) - const sources: ActivityPumpSource[] = [] - const stdout = proc.observed?.stdout - if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) - const stderr = proc.observed?.stderr - if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) - void pumpActivityOutput(handle, sources, { pollMs, done: proc.done }) - .catch((error: unknown) => { - ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`) - }) - .then(async () => { - // The pump can fail while the process is still running; the outcome - // mapping requires a settled handle, and `end` is first-wins, so an - // early mapping would freeze a wrong terminal state onto the row. - // `proc.done` never rejects. - await proc.done - handle.end(outcome(proc)) - }) - .catch((error: unknown) => { - ctx.logger.warn(`activity settlement mapping for ${handle.id} failed: ${String(error)}`) - }) - } catch (error: unknown) { - ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`) - } } /* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/observe.spec.ts index c9024203fb..0fbdcb80de 100644 --- a/packages/shell/tool-pwsh/tests/observe.spec.ts +++ b/packages/shell/tool-pwsh/tests/observe.spec.ts @@ -8,7 +8,6 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' -import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' @@ -81,10 +80,9 @@ async function setup() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) - await ctx.plugin(LocalActivityRegistry) await ctx.plugin(BashEnvPlugin) await ctx.plugin(FakePwsh) - await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) + await ctx.plugin(ToolPwsh, { recordPollMs: 5 }) return { ctx, pwsh: ctx.shell as FakePwsh } } @@ -108,8 +106,8 @@ async function until(read: () => T | undefined, timeoutMs = 5_000): Promise { - it('mirrors observed streams with channels and settles with the mapped outcome', async () => { +describe('background pwsh record', () => { + it('streams observed channels into the job record and settles with the mapped outcome', async () => { const { ctx, pwsh } = await setup() const stdout = { text: '' } const stderr = { text: '' } @@ -118,29 +116,32 @@ describe('background pwsh observation', () => { await call(ctx, { command: 'Get-Progress', description: 'test command', run_in_background: true }) const job = ctx.jobs.list()[0] - const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id)) - expect(row.kind).toBe('pwsh') + expect(job).toBeDefined() + expect(job!.kind).toBe('pwsh') + expect(job!.outputTotal).toBe(0) stdout.text = 'progress-line\n' stderr.text = 'warn-line\n' await until(() => { - const chunks = ctx.activities.read(row.id, 0).chunks + const chunks = ctx.jobs.readRecord(job!.id, 0).chunks return chunks.some(chunk => chunk.text.includes('progress-line')) && chunks.some(chunk => chunk.text.includes('warn-line')) ? true : undefined }) - const chunks = ctx.activities.read(row.id, 0).chunks + const chunks = ctx.jobs.readRecord(job!.id, 0).chunks expect(chunks.find(chunk => chunk.text.includes('progress-line'))?.channel).toBe('stdout') expect(chunks.find(chunk => chunk.text.includes('warn-line'))?.channel).toBe('stderr') scripted.finish() - await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined) - expect(ctx.activities.get(row.id).detail).toBe('exit code: 0') + await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) + expect(ctx.jobs.get(job!.id).detail).toBe('exit code: 0') }) - it('a pump failure waits for process settlement before mapping the outcome', async () => { + it('a pump failure warns and never fakes a terminal state onto the running job', async () => { const { ctx, pwsh } = await setup() + const warn = vi.fn() + ctx.logger.warn = warn as never let resolveDone: () => void = () => {} const done = new Promise((resolve) => { resolveDone = resolve }) const proc: ShellProcess = { @@ -157,70 +158,36 @@ describe('background pwsh observation', () => { } pwsh.backgroundHandler = () => proc await call(ctx, { command: 'Get-Broken', description: 'test command', run_in_background: true }) - const row = await until(() => ctx.activities.list()[0]) - // The pump rejected on its first poll; the still-running process must not - // be mapped to a terminal state until it actually settles. + const job = ctx.jobs.list()[0] + await until(() => warn.mock.calls.some(args => String(args[0]).includes('record observation pump')) ? true : undefined) + // The pump already failed; the job must wait for real settlement rather + // than freezing a fake terminal state onto the running process. await new Promise(resolve => setTimeout(resolve, 50)) - expect(ctx.activities.list()[0]?.status).toBe('running') + expect(ctx.jobs.get(job!.id).status).toBe('running') proc.status = 'completed' proc.exitCode = 0 resolveDone() - await until(() => ctx.activities.list()[0]?.status !== 'running' ? true : undefined) - expect(ctx.activities.list()[0]).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) - expect(String(row.id)).toContain('pwsh') + await until(() => ctx.jobs.get(job!.id).status !== 'running' ? true : undefined) + expect(ctx.jobs.get(job!.id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) }) - it('a throwing end() is swallowed by the settlement-mapping catch', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - await ctx.plugin(ToolTasks) - await ctx.plugin({ - name: 'end-throwing-activities-probe', - apply(child: Context) { - child.provide('activities', { - open: () => ({ - id: 'pwsh-1', - append() {}, - updateDetail() {}, - end() { throw new Error('end boom') }, - }), - }) - }, - }) - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(FakePwsh) - await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) - const pwsh = ctx.shell as FakePwsh - const warn = vi.fn() - ctx.logger.warn = warn as never - const handle = observableProcess({ stdout: { text: '' }, stderr: { text: '' } }) - pwsh.backgroundHandler = () => handle.proc - await call(ctx, { command: 'Get-Doom', description: 'test command', run_in_background: true }) - handle.finish() - await until(() => warn.mock.calls.some(args => String(args[0]).includes('activity settlement mapping')) ? true : undefined) - }) - - it('a backend without observed readers still yields a status-only process', async () => { + it('a backend without observed readers still yields a status-only record', async () => { const { ctx, pwsh } = await setup() const scripted = observableProcess() pwsh.backgroundHandler = () => scripted.proc await call(ctx, { command: 'Start-Job', description: 'test command', run_in_background: true }) const job = ctx.jobs.list()[0] - const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id)) - expect(ctx.activities.read(row.id, 0).chunks).toEqual([]) + expect(ctx.jobs.readRecord(job!.id, 0).chunks).toEqual([]) scripted.finish() - await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined) - expect(ctx.activities.get(row.id).outputTotal).toBe(0) + await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) + expect(ctx.jobs.get(job!.id).outputTotal).toBe(0) }) }) -describe('owned and degraded observation (pwsh)', () => { - it('mirrors an owned background run under the owning session', async () => { +describe('owned background record (pwsh)', () => { + it('keeps an owned background run record under the owning session', async () => { const { ctx, pwsh } = await setup() const owner = { id: SessionId('pwsh-observe-owner'), @@ -240,40 +207,10 @@ describe('owned and degraded observation (pwsh)', () => { }) const job = ctx.jobs.list(owner)[0] expect(job).toBeDefined() - const row = await until(() => ctx.activities.list(owner).find(item => item.correlation?.jobId === job!.id)) - expect(row.ownerSession).toBe(owner.id) + expect(job!.ownerSession).toBe(owner.id) + expect(() => ctx.jobs.readRecord(job!.id, 0)).toThrow(/belongs to another session/) + expect(ctx.jobs.readRecord(job!.id, 0, owner).chunks).toEqual([]) scripted.finish() await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined) }) - - it('swallows a throwing observation registry and keeps the job alive', async () => { - // The FakePwsh composition with a synchronously throwing open(): the - // failure lands in the outer observation catch and the job runs on. - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - await ctx.plugin(ToolTasks) - await ctx.plugin({ - name: 'broken-activities-probe', - apply(child: Context) { - child.provide('activities', { open() { throw new Error('observation boom') } }) - }, - }) - await ctx.plugin(BashEnvPlugin) - await ctx.plugin(FakePwsh) - const warn = vi.fn() - ctx.logger.warn = warn as never - await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) - const pwsh = ctx.shell as FakePwsh - const scripted = observableProcess() - pwsh.backgroundHandler = () => scripted.proc - await call(ctx, { command: 'Get-Slow', description: 'test command', run_in_background: true }) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation unavailable')) - const job = ctx.jobs.list()[0] - expect(job?.status).toBe('running') - scripted.finish() - await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) - }) }) diff --git a/packages/shell/tool-pwsh/tsconfig.json b/packages/shell/tool-pwsh/tsconfig.json index d38c8f031d..59a4d9478c 100644 --- a/packages/shell/tool-pwsh/tsconfig.json +++ b/packages/shell/tool-pwsh/tsconfig.json @@ -52,9 +52,6 @@ }, { "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../activity/activity" } ] } diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index a7096838da..2291b274a5 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -68,7 +68,7 @@ The consumer owns the model-facing schema, the `tool:` system-prompt g For a root transport execution (`exec.parent` absent), the tool projects the run into the calling Agent's Session with four log-only events: run-start after `start()` returns, member starts and endings filtered by `run.id`, then run-end only after the result is available and disposal reaches quiescence. Nested transport calls execute normally but write no record. The first failed Session append disables later recording for that run with one warning, leaving either no record or a legal continuous prefix without changing the tool result or cleanup. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append, while accepting missing terminal suffixes. -Beside the durable record, each recorded top-level run is mirrored best-effort into the optional `ctx.activities` registry as a `workflow` activity: the otherwise-unconsumed `workflow/phase` and `workflow/log` events plus member lifecycle lines stream into it, so the Web client shows the run's live narration the session log deliberately does not carry per line. Without the registry, or on any mirror failure, the run is unaffected. +The engine's `workflow/phase` and `workflow/log` events have no per-line durable or observer surface from this tool: the session log deliberately records run and member lifecycle only, and the Web transcript derives from those records. ### Render intent @@ -80,7 +80,6 @@ Decided up front per the [render-intent Agent Note](../../../.agents/notes/imple |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: tool registration, run lifecycle, recorder wiring | | [`src/types.ts`](src/types.ts) | The four log-only record event payloads and their `SessionEventMap` declaration | -| [`src/activity.ts`](src/activity.ts) | Best-effort activity mirror for top-level runs | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: durable workflow-record protocol validation | diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index a9707f1456..4f148077b5 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -38,7 +38,6 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -52,8 +51,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", - "@deepseek-ai/dsh-activity-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/workflow/tool-workflow/src/activity.ts b/packages/workflow/tool-workflow/src/activity.ts deleted file mode 100644 index 36b251b894..0000000000 --- a/packages/workflow/tool-workflow/src/activity.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Best-effort activity mirror for top-level workflow runs: opens one - * `workflow` activity per run and streams the engine's `workflow/phase`, - * `workflow/log`, and member lifecycle events into it as text lines, so the - * Web client shows live progress the session log deliberately does not carry - * per line. Without a `ctx.activities` registry every operation is a no-op, - * and ANY mirror failure is logged and swallowed — the run is unaffected. - * - * @module @deepseek-ai/dsh-tool-workflow/activity - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ActivityHandle } from '@deepseek-ai/dsh-activity' -import type { WorkflowRun, WorkflowRunId, WorkflowStopReason } from '@deepseek-ai/dsh-workflow' - -declare module '@deepseek-ai/dsh-activity' { - interface ActivityKindMap { - workflow: 'workflow' - } -} - -/** Activity taps for the three points the tool already tracks a run at. */ -export interface WorkflowActivityMirror { - /** - * Open the run's activity. Call once per recorded top-level run. - * @param run - the started run (id and meta name). - * @param owner - the calling agent, when the call has one. - */ - start(run: WorkflowRun, owner: Agent | undefined): void - /** - * End the run's activity with the mapped stop reason. - * @param runId - the settled run. - * @param stopReason - how the run stopped. - */ - finish(runId: WorkflowRunId, stopReason: WorkflowStopReason): void - /** - * Drop tracking for a run that ends without a settled reason. First-wins - * with {@link finish}: after a normal finish this is inert. - * @param runId - the abandoned run. - */ - abandon(runId: WorkflowRunId): void -} - -/** Map a run's stop reason onto the activity outcome vocabulary. */ -function outcomeOf(stopReason: WorkflowStopReason): { status: 'completed' | 'failed' | 'killed'; detail: string } { - switch (stopReason) { - case 'completed': return { status: 'completed', detail: 'completed' } - case 'cancelled': return { status: 'killed', detail: 'cancelled' } - case 'error': return { status: 'failed', detail: 'error' } - } -} - -/** - * Create the run-to-activity mirror and subscribe the engine's live progress - * events for the runs it tracks. - * @param ctx - plugin context used for the optional registry and warning logs. - * @returns the mirror taps the tool wires beside its durable recorder. - */ -export function createWorkflowActivityMirror(ctx: Context): WorkflowActivityMirror { - const active = new Map() - const contained = (operation: string, act: () => void): void => { - try { - act() - } catch (error: unknown) { - ctx.logger.warn(`workflow activity mirror ${operation} failed: ${String(error)}`) - } - } - - ctx.on('workflow/phase', (info, title) => { - const handle = active.get(info.id) - if (handle === undefined) return - contained('phase', () => { - handle.updateDetail(title) - handle.append(`▸ ${title}\n`) - }) - }) - ctx.on('workflow/log', (info, message) => { - const handle = active.get(info.id) - if (handle === undefined) return - contained('log', () => { handle.append(`${message}\n`) }) - }) - ctx.on('workflow/agent-start', (info, agent) => { - const handle = active.get(info.id) - if (handle === undefined) return - contained('agent-start', () => { handle.append(`agent #${agent.seq} ${agent.label} started\n`) }) - }) - ctx.on('workflow/agent-end', (info, agent) => { - const handle = active.get(info.id) - if (handle === undefined) return - contained('agent-end', () => { handle.append(`agent #${agent.seq} ${agent.outcome}\n`) }) - }) - - return { - start(run, owner) { - contained('open', () => { - const activities = ctx.get('activities') - if (activities === undefined) return - active.set(run.id, activities.open({ - kind: 'workflow', - label: run.meta.name, - ...owner !== undefined ? { owner } : {}, - })) - }) - }, - finish(runId, stopReason) { - const handle = active.get(runId) - active.delete(runId) - if (handle === undefined) return - contained('finish', () => { handle.end(outcomeOf(stopReason)) }) - }, - abandon(runId) { - const handle = active.get(runId) - active.delete(runId) - if (handle === undefined) return - contained('abandon', () => { handle.end({ status: 'killed', detail: 'run abandoned' }) }) - }, - } -} diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 022d8e4266..77e21672b4 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -24,7 +24,6 @@ import type { ToolWorkflowAgentEndData, ToolWorkflowAgentStartData, ToolWorkflowRunEndData, ToolWorkflowRunStartData, } from './types.ts' -import { createWorkflowActivityMirror } from './activity.ts' export const name = 'tool-workflow' export const inject = ['tools', 'workflowEngine', 'systemPrompt'] @@ -207,7 +206,6 @@ export function apply(ctx: Context, config: Config): void { // fields; the assertion records that resolution, not a hidden fallback. const { toolName, maxResultChars } = config as ResolvedConfig const recorder = createWorkflowRecorder(ctx) - const mirror = createWorkflowActivityMirror(ctx) // Usage policy ships with the tool (the master convention: tool guidance // lives in tool plugins as prompt sections, not in the deployment persona). ctx.systemPrompt.section({ @@ -294,7 +292,6 @@ export function apply(ctx: Context, config: Config): void { // worker messages, after start() returns and this run record is active. if (recordsRun) { recorder.start(parent.session, run) - mirror.start(run, parent) } // Bridge the tool's abort signal to the run: if the parent step is aborted while the @@ -327,12 +324,10 @@ export function apply(ctx: Context, config: Config): void { /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ if (result === undefined) throw new Error('workflow run settled without a result') recorder.finish(run.id, result.stopReason) - mirror.finish(run.id, result.stopReason) } } finally { if (recordsRun) { recorder.abandon(run.id) - mirror.abandon(run.id) } } } diff --git a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts deleted file mode 100644 index 8f82cb2841..0000000000 --- a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' -import { SessionId } from '@deepseek-ai/dsh-session' -import type { WorkflowRun, WorkflowRunId, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' -import { createWorkflowActivityMirror } from '../src/activity.ts' - -function runStub(id = 'run-1'): { run: WorkflowRun; info: WorkflowRunInfo } { - const info = { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRunInfo - return { - run: { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRun, - info, - } -} - -function retainedText(ctx: Context): string { - const row = ctx.activities.list()[0] - if (row === undefined) throw new Error('no mirrored activity') - return ctx.activities.read(row.id, 0).chunks.map(chunk => chunk.text).join('') -} - -describe('workflow activity mirror', () => { - it('opens one workflow activity per run and streams phase, log, and member lines', async () => { - const ctx = new Context() - await ctx.plugin(LocalActivityRegistry) - const mirror = createWorkflowActivityMirror(ctx) - const { run, info } = runStub() - mirror.start(run, undefined) - - const row = ctx.activities.list()[0] - expect(row?.kind).toBe('workflow') - expect(row?.label).toBe('docs-sweep') - - ctx.emit('workflow/phase', info, 'Scan') - ctx.emit('workflow/log', info, '3 files queued') - ctx.emit('workflow/agent-start', info, { seq: 1, label: 'reader', childId: 'c1' } as never) - ctx.emit('workflow/agent-end', info, { seq: 1, label: 'reader', childId: 'c1', outcome: 'completed' } as never) - expect(ctx.activities.get(row!.id).detail).toBe('Scan') - expect(retainedText(ctx)).toBe('▸ Scan\n3 files queued\nagent #1 reader started\nagent #1 completed\n') - - mirror.finish(run.id, 'completed') - const settled = ctx.activities.get(row!.id) - expect(settled.status).toBe('completed') - // The always-run abandon after a normal finish is inert. - mirror.abandon(run.id) - expect(ctx.activities.get(row!.id).status).toBe('completed') - }) - - - it('opens an owned run under the owning agent session', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalActivityRegistry) - const owner = { - id: SessionId('workflow-owner'), - session: { id: SessionId('workflow-owner') }, - status: 'idle', - ctx, - } as unknown as Agent - ctx.agents.register(owner) - const mirror = createWorkflowActivityMirror(ctx) - const { run } = runStub('run-owned') - mirror.start(run, owner) - // The owned row is fenced to its owner's view. - const row = ctx.activities.list(owner)[0] - expect(row?.ownerSession).toBe(owner.id) - mirror.finish(run.id, 'completed') - }) - it('maps cancelled and error stops, and ends an abandoned run as killed', async () => { - const ctx = new Context() - await ctx.plugin(LocalActivityRegistry) - const mirror = createWorkflowActivityMirror(ctx) - - const cancelled = runStub('run-c') - mirror.start(cancelled.run, undefined) - mirror.finish(cancelled.run.id, 'cancelled') - - const failed = runStub('run-e') - mirror.start(failed.run, undefined) - mirror.finish(failed.run.id, 'error') - - const orphaned = runStub('run-o') - mirror.start(orphaned.run, undefined) - mirror.abandon(orphaned.run.id) - - const byLabelOrder = ctx.activities.list() - expect(byLabelOrder.map(row => [row.status, row.detail])).toEqual([ - ['killed', 'cancelled'], - ['failed', 'error'], - ['killed', 'run abandoned'], - ]) - }) - - it('ignores events for untracked runs and stays inert without the registry', async () => { - const bare = new Context() - const inert = createWorkflowActivityMirror(bare) - const { run, info } = runStub() - // No registry: every tap is a no-op rather than a throw. - inert.start(run, undefined) - bare.emit('workflow/log', info, 'dropped') - inert.finish(run.id, 'completed') - inert.abandon(run.id) - - const ctx = new Context() - await ctx.plugin(LocalActivityRegistry) - createWorkflowActivityMirror(ctx) - // Events for a run the mirror never opened change nothing. - ctx.emit('workflow/phase', info, 'ghost') - expect(ctx.activities.list()).toEqual([]) - }) - - it('contains a throwing registry so the run path never breaks', async () => { - const ctx = new Context() - const warn = vi.fn() - ctx.logger.warn = warn as never - await ctx.plugin({ - name: 'broken-activities-probe', - apply(child: Context) { - child.provide('activities', { open() { throw new Error('mirror boom') } }) - }, - }) - const mirror = createWorkflowActivityMirror(ctx) - const { run } = runStub() - expect(() => { mirror.start(run, undefined) }).not.toThrow() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('workflow activity mirror open failed')) - }) -}) diff --git a/packages/workflow/tool-workflow/tsconfig.json b/packages/workflow/tool-workflow/tsconfig.json index a4cde5cbef..bfd38e61d4 100644 --- a/packages/workflow/tool-workflow/tsconfig.json +++ b/packages/workflow/tool-workflow/tsconfig.json @@ -37,9 +37,6 @@ }, { "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../activity/activity" } ] } From 6e8a7a50ea85d3f9835a57f064fb9cc4ed6156d0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 18:03:09 +0800 Subject: [PATCH 015/141] feat(api): serve job-record observation from the session namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionJob rows carry outputTotal exactly for record-declaring jobs, and a new session.observeJob stream serves one job's retained record from an absolute offset (anchor, coalesced output frames, terminal status) with the fenced read resolved from the request's session — the same authorization shape as the jobs roster. The client half gains ctx.jobOutput: the reference-counted per-job observation service and its accumulated view model, ported from the activity controller this replaces. --- .../session-controller/src/client/index.ts | 4 + .../src/client/job-output.ts | 348 +++++++++++++++ .../api/session-controller/src/control.ts | 1 + packages/api/session-controller/src/index.ts | 44 ++ .../api/session-controller/src/observe-job.ts | 189 ++++++++ packages/api/session-controller/src/types.ts | 63 +++ .../tests/control-jobs.host.spec.ts | 19 + .../tests/job-output.client.spec.ts | 413 ++++++++++++++++++ .../tests/observe-job.host.spec.ts | 298 +++++++++++++ .../session-controller/tests/test-remote.ts | 4 + .../api/session-controller/tsconfig.host.json | 1 + 11 files changed, 1384 insertions(+) create mode 100644 packages/api/session-controller/src/client/job-output.ts create mode 100644 packages/api/session-controller/src/observe-job.ts create mode 100644 packages/api/session-controller/tests/job-output.client.spec.ts create mode 100644 packages/api/session-controller/tests/observe-job.host.spec.ts diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 4bad2bb8c2..78a54b37ef 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent/types' import { createSessionControlStream } from './transport.ts' +import { ClientJobOutput, ClientJobOutputModel } from './job-output.ts' import { ClientSessions } from './sessions/service.ts' import type { SessionRemotes } from './sessions/remotes.ts' import type {} from '../remote-events.ts' @@ -23,6 +24,8 @@ export type { } from './transport.ts' export { createScope, scopeOf } from './scope.ts' export type { AgentContext, AgentScopeHandle } from './scope.ts' +export { ClientJobOutput, ClientJobOutputModel } from './job-output.ts' +export type { IJobOutput, JobOutputSnapshot, JobOutputSource, ObservedJob } from './job-output.ts' export { SessionCreateError, SessionForkError } from './sessions/service.ts' export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' export type { @@ -89,6 +92,7 @@ export const inject = [ export function apply(ctx: Context): void { const remotes = ctx.remote as unknown as SessionRemotes const sessions = new ClientSessions(ctx, remotes) + new ClientJobOutput(ctx, remotes, new ClientJobOutputModel()) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) ctx.remote.$on('api-session/status', (sessionId, running) => { diff --git a/packages/api/session-controller/src/client/job-output.ts b/packages/api/session-controller/src/client/job-output.ts new file mode 100644 index 0000000000..933c104497 --- /dev/null +++ b/packages/api/session-controller/src/client/job-output.ts @@ -0,0 +1,348 @@ +/** + * React-free client job-record observation: per-job accumulated output views + * over the `session.observeJob` stream, reference-counted per job so + * overlapping panels share one stream. The roster itself rides the session + * control stream (`jobsBySession`); this module carries only the output. + * @module @deepseek-ai/dsh-api-session-controller/client/job-output + */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionJob, SessionObserveJobFrame } from '../types.ts' +import type { SessionRemote } from './transport.ts' + +/** Bounded per-job render tail, in UTF-16 code units. */ +const RENDER_TAIL_LIMIT = 128 * 1024 + +/** One observed job's live view state. */ +export interface ObservedJob { + readonly jobId: JobId + /** Accumulated output tail, bounded to the render limit. */ + readonly text: string + /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ + readonly gapBefore: boolean + /** Latest status delivered on the observation stream. */ + readonly status: SessionJob['status'] + readonly detail?: string + /** True while the observation stream is open and the job has not settled. */ + readonly streaming: boolean + /** Terminal observation failure, when the stream ended abnormally. */ + readonly error?: string +} + +/** Immutable client job-output state. */ +export interface JobOutputSnapshot { + /** Live observation state keyed by job id. */ + readonly observed: Readonly> +} + +/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */ +interface ObservedState { + view: ObservedJob + /** Resume offset for the next generation's `from`. */ + cursor: number | undefined +} + +/** + * Owns per-job observation state. Pure data plus subscriptions — transport + * wiring stays in the client service, UI stays in slot components. + */ +export class ClientJobOutputModel { + private readonly observedStates = new Map() + private readonly listeners = new Set<() => void>() + private snapshotCache: JobOutputSnapshot = { observed: {} } + private snapshotDirty = false + + /** + * Read the identity-stable current snapshot. + * @returns the cached snapshot, rebuilt only after a change. + */ + getSnapshot(): JobOutputSnapshot { + if (this.snapshotDirty) { + const observed: Record = {} + for (const [id, state] of this.observedStates) observed[id] = state.view + this.snapshotCache = { observed } + this.snapshotDirty = false + } + return this.snapshotCache + } + + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * The resume offset for one job's next observation generation. + * @param id - observed job. + * @returns the last accepted `next`, or undefined for a fresh observation. + */ + cursorOf(id: JobId): number | undefined { + return this.observedStates.get(String(id))?.cursor + } + + /** + * Install or reset observation state when a generation's anchor arrives. + * @param id - observed job. + * @param frame - the generation's `opened` anchor. + */ + observeOpened(id: JobId, frame: Extract): void { + const existing = this.observedStates.get(String(id)) + // A fresh view anchored past offset zero starts after an evicted head + // (fresh observations anchor at the registry's earliest retained byte), so + // it owes the same gap mark a live observer earned from lossy reads. A + // resume that already accumulated text keeps its recorded gap state. + const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 + const view: ObservedJob = { + jobId: id, + text: existing?.view.text ?? '', + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: true, + } + this.observedStates.set(String(id), { view, cursor: frame.from }) + this.changed() + } + + /** + * Append one output frame's chunks to the bounded render tail. + * @param id - observed job. + * @param frame - a coalesced `output` frame. + */ + observeOutput(id: JobId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') + let gapBefore = state.view.gapBefore || frame.lossy === true + || frame.chunks.some(chunk => chunk.gapBefore === true) + if (text.length > RENDER_TAIL_LIMIT) { + let cut = text.length - RENDER_TAIL_LIMIT + // Never split a surrogate pair at the render bound. + const unit = text.charCodeAt(cut) + if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 + text = text.slice(cut) + gapBefore = true + } + state.view = { ...state.view, text, gapBefore } + state.cursor = frame.next + this.changed() + } + + /** + * Record the terminal status delivered at the end of the observation stream. + * @param id - observed job. + * @param frame - the terminal `status` frame. + */ + observeStatus(id: JobId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + state.view = { + ...state.view, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: false, + } + this.changed() + } + + /** + * Record a terminal observation failure. + * @param id - observed job. + * @param error - the stream's terminal failure. + */ + observeFailed(id: JobId, error: unknown): void { + const state = this.observedStates.get(String(id)) + if (state === undefined) return + state.view = { ...state.view, streaming: false, error: String(error) } + this.changed() + } + + /** + * Drop observation state after the last observer stops. + * @param id - the no-longer-observed job. + */ + observeStopped(id: JobId): void { + if (!this.observedStates.delete(String(id))) return + this.changed() + } + + private changed(): void { + this.snapshotDirty = true + notifySubscribers(this.listeners, 'job output') + } +} + +/** Bare observable source for the client job-output snapshot. */ +export interface JobOutputSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): JobOutputSnapshot + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void +} + +/** The job-output client service face. */ +export interface IJobOutput { + /** Per-job observation state. */ + readonly state: JobOutputSource + /** + * Start observing one job's live record; reference-counted, so two viewers + * of the same job share one stream. + * @param sessionId - owning session used for the fenced read; undefined for an unowned job. + * @param id - job to observe. + * @returns stop function releasing this observer's reference. + */ + observe(sessionId: SessionId | undefined, id: JobId): () => void +} + +/** One reference-counted observation stream. */ +interface ObservationEntry { + refs: number + stopped: boolean + dispose: () => Promise +} + +/** Remote face the observation runner drives. */ +type JobObserveRemote = Pick & { readonly session: Pick } + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free client job-record observation control. */ + jobOutput: IJobOutput + } +} + +/** Owns the bare job-output snapshot and per-job observation streams. */ +export class ClientJobOutput extends Service implements IJobOutput { + readonly state: JobOutputSource + private readonly entries = new Map() + + /** + * @param ctx - client root Context. + * @param remote - generated session namespace plus the Gateway stream factory. + * @param model - shared client job-output model. + */ + constructor( + ctx: Context, + private readonly remote: JobObserveRemote, + private readonly model: ClientJobOutputModel, + ) { + super(ctx, 'jobOutput') + this.state = model + ctx.effect(() => () => { + const open = [...this.entries.values()] + this.entries.clear() + for (const entry of open) { + entry.stopped = true + void entry.dispose() + } + }, 'session-controller.client.observations') + } + + observe(sessionId: SessionId | undefined, id: JobId): () => void { + const key = String(id) + const existing = this.entries.get(key) + if (existing !== undefined && !existing.stopped) { + existing.refs += 1 + return this.releaser(key, existing) + } + const entry = this.startObservation(sessionId, id) + this.entries.set(key, entry) + return this.releaser(key, entry) + } + + /** + * Release closures bind the exact entry they were minted for, never the + * map's current occupant: a later `observe()` on the same id may have + * replaced a stopped entry, and decrementing or disposing through the key + * alone would tear down that newer stream's references. + */ + private releaser(key: string, entry: ObservationEntry): () => void { + let released = false + return () => { + if (released) return + released = true + entry.refs -= 1 + if (entry.refs > 0) return + if (this.entries.get(key) === entry) this.entries.delete(key) + entry.stopped = true + void entry.dispose().then(() => { + // Clear the view only while no successor observation holds the key: + // a re-expand inside the dispose round-trip already re-anchored the + // model, and a stale clear would blank its panel for good. + if (this.entries.has(key)) return + // The key is the stringified branded id this releaser was minted for. + this.model.observeStopped(key as JobId) + }) + } + } + + private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry { + const name = `job observation ${String(id)}` + const stream = this.remote.$stream({ + name, + open: (signal) => { + const from = this.model.cursorOf(id) + return this.remote.session.observeJob( + { + jobId: id, + ...sessionId !== undefined ? { sessionId } : {}, + ...from !== undefined ? { from } : {}, + }, + signal, + ) + }, + // A premature end after the anchor is retryable (a Host reload closes the + // generation); resuming from the cursor loses nothing. An end before the + // anchor is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before settlement`) + : new Error(`${name} ended before its anchor`), + }) + const entry: ObservationEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + const frame = item.value + if (frame.type === 'opened') { + this.model.observeOpened(id, frame) + item.accept() + continue + } + if (frame.type === 'output') { + this.model.observeOutput(id, frame) + continue + } + // Terminal status: leave the loop before the generation end is + // classified, then close the stream for good. + this.model.observeStatus(id, frame) + break + } + } catch (error) { + if (!entry.stopped) this.model.observeFailed(id, error) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } +} diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 1a03011644..aa9f0a77be 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -212,5 +212,6 @@ function jobView(job: JobSnapshot): SessionJob { ...(job.detail === undefined ? {} : { detail: job.detail }), startedAt: job.startedAt, ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }), + ...(job.outputTotal === undefined ? {} : { outputTotal: job.outputTotal }), } } diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 572f895754..99dad0a75e 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -2,6 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-jobs' import { errorChain } from '@deepseek-ai/dsh-llm' import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' @@ -15,6 +16,7 @@ import { import { SessionCommandController } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' +import { observeJobRecord } from './observe-job.ts' import { SessionFileReferences } from './file-references.ts' import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts' import { buildModelCatalog } from './catalog.ts' @@ -35,6 +37,8 @@ import type { SessionForkValue, SessionListRequest, SessionListValue, + SessionObserveJobFrame, + SessionObserveJobRequest, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionPage, @@ -63,12 +67,22 @@ declare module '@deepseek-ai/cordis' { } } +/** Default coalescing window between job-observation reads, in milliseconds. */ +const DEFAULT_OBSERVE_FLUSH_MS = 100 + +/** Default soft byte budget per job-observation output frame. */ +const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024 + /** Session Controller deployment policy. */ export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean + /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number } /** Host integrations replaceable by direct unit tests. */ @@ -96,6 +110,8 @@ export class SessionController extends TypertRemoteService { static Config: z = z.object({ coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), nativeOpen: z.boolean(), + observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), + observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES), }) private readonly agents: ApiSessionAgentController @@ -106,6 +122,8 @@ export class SessionController extends TypertRemoteService { private readonly openPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean private readonly promotions = new Set>() + private readonly observeFlushMs: number + private readonly observeMaxFrameBytes: number /** * @param ctx - Host context containing the Session capability assembly. @@ -130,6 +148,8 @@ export class SessionController extends TypertRemoteService { this.openPath = internals.openPath ?? openNativePath this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) + this.observeFlushMs = config.observeFlushMs ?? DEFAULT_OBSERVE_FLUSH_MS + this.observeMaxFrameBytes = config.observeMaxFrameBytes ?? DEFAULT_OBSERVE_MAX_FRAME_BYTES ctx.plugin(SessionFileReferences) ctx.plugin(SessionSkillCatalog) @@ -386,6 +406,30 @@ export class SessionController extends TypertRemoteService { return this.controlState.control(signal) } + /** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ + @Remote({ mode: 'stream' }) + observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable { + const registry = this.ctx.get('jobs') + if (registry === undefined) { + throw new Error('job observation unavailable: no job registry is loaded (load @deepseek-ai/dsh-jobs-local)') + } + const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId) + return observeJobRecord(registry, request, { + flushMs: this.observeFlushMs, + maxFrameBytes: this.observeMaxFrameBytes, + caller, + }, signal) + } + } export { buildModelCatalog } diff --git a/packages/api/session-controller/src/observe-job.ts b/packages/api/session-controller/src/observe-job.ts new file mode 100644 index 0000000000..516f48779f --- /dev/null +++ b/packages/api/session-controller/src/observe-job.ts @@ -0,0 +1,189 @@ +/** Per-job record observation generations: anchor, coalesced output, terminal status. */ + +import type { Agent } from '@deepseek-ai/dsh-agent' +import { JobId } from '@deepseek-ai/dsh-jobs' +import type { JobRegistry, JobSnapshot } from '@deepseek-ai/dsh-jobs' +import type { SessionJobWireChunk, SessionObserveJobFrame, SessionObserveJobRequest } from './types.ts' + +/** Cadence and framing bounds for one observation generation. */ +export interface ObserveJobOptions { + /** Coalescing window after a wake before reading, in milliseconds. */ + readonly flushMs: number + /** + * Soft byte budget per `output` frame. Accumulated chunks flush once the + * budget is met; one chunk larger than the budget ships whole (the + * registry's retention cap is the hard bound on any single chunk). + */ + readonly maxFrameBytes: number + /** The live owning agent for fenced reads, resolved from the request's session. */ + readonly caller: Agent | undefined +} + +function isTerminal(status: JobSnapshot['status']): boolean { + return status !== 'running' && status !== 'stopping' +} + +/** A record job's total offset; the anchor check proved the declaration and it never leaves. */ +function recordTotal(snapshot: JobSnapshot): number { + /* v8 ignore next -- a declared record never loses its offsets. */ + return snapshot.outputTotal ?? 0 +} + +/** Wake-flag waiter: a wake between waits is never lost. */ +class OutputWaiter { + private dirty = false + private resolve: (() => void) | undefined + + wake(): void { + this.dirty = true + this.resolve?.() + } + + /** + * Resolve on the next wake, immediately when one already arrived, or on abort. + * @param signal - generation cancellation. + */ + wait(signal: AbortSignal): Promise { + if (this.dirty || signal.aborted) { + this.dirty = false + return Promise.resolve() + } + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one wait owns the sole installed resolver. */ + if (this.resolve === finish) this.resolve = undefined + this.dirty = false + resolve() + } + this.resolve = finish + signal.addEventListener('abort', finish, { once: true }) + }) + } +} + +function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} + +/** + * Stream one job's retained record output from an absolute offset: one + * `opened` anchor, coalesced `output` frames as the record advances, then one + * terminal `status` after the settled job is drained, after which the + * generation closes normally. Reads are non-consuming — the model-facing + * cursor and notice state never observe them; reconnecting callers resume by + * passing the last frame's `next` as `from`. Throws for a job that declared + * no record. + * @param registry - the live job registry. + * @param request - target job and optional resume offset. + * @param options - cadence, framing bounds, and the fenced-read caller. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the observation frame sequence for one generation. + */ +export async function* observeJobRecord( + registry: JobRegistry, + request: SessionObserveJobRequest, + options: ObserveJobOptions, + signal: AbortSignal, +): AsyncIterable { + if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { + throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) + } + signal.throwIfAborted() + const id = JobId(String(request.jobId)) + const waiter = new OutputWaiter() + // Subscribe before the first read so an append between the anchor read and + // the wait cannot be missed. + const unsubscribe = registry.onOutput((changed) => { + if (changed === id) waiter.wake() + }) + try { + const caller = options.caller + let snapshot = registry.get(id, caller) + if (snapshot.outputTotal === undefined || snapshot.outputEarliest === undefined) { + throw new Error(`job ${id} declared no output record`) + } + let cursor = request.from ?? snapshot.outputEarliest + yield { + type: 'opened', + jobId: id, + from: cursor, + earliest: snapshot.outputEarliest, + total: snapshot.outputTotal, + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + } + while (!signal.aborted) { + const read = registry.readRecord(id, cursor, caller) + if (read.chunks.length > 0) { + yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes) + } + cursor = read.next + snapshot = registry.get(id, caller) + if (isTerminal(snapshot.status) && cursor >= recordTotal(snapshot)) { + yield { + type: 'status', + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + // Settlement always stamps finishedAt; the guard only discharges the optional field type. + /* v8 ignore next */ + ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, + } + return + } + await waiter.wait(signal) + // Let a burst accumulate so producer chatter becomes bounded frames. + await sleep(options.flushMs, signal) + } + } finally { + unsubscribe() + } +} + +/** Split one read into frames along the soft per-frame byte budget. */ +function* outputFrames( + chunks: readonly SessionJobWireChunk[], + next: number, + lossy: boolean, + maxFrameBytes: number, +): Iterable { + let batch: SessionJobWireChunk[] = [] + let batchBytes = 0 + let flaggedLossy = lossy + for (const chunk of chunks) { + batch.push(chunk) + batchBytes += Buffer.byteLength(chunk.text, 'utf8') + if (batchBytes >= maxFrameBytes) { + const last = batch[batch.length - 1] + /* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */ + const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, 'utf8') + /* v8 ignore stop */ + yield { + type: 'output', + chunks: batch, + next: end, + ...flaggedLossy ? { lossy: true as const } : {}, + } + flaggedLossy = false + batch = [] + batchBytes = 0 + } + } + if (batch.length > 0 || flaggedLossy) { + yield { + type: 'output', + chunks: batch, + next, + ...flaggedLossy ? { lossy: true as const } : {}, + } + } +} diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c0b25fcf2c..cf59e29ad4 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -469,6 +469,12 @@ export interface SessionJob { readonly detail?: string readonly startedAt: number readonly finishedAt?: number + /** + * Total UTF-8 bytes the job's observation record holds so far. Present + * exactly when the job declared a record — the row is observable through + * `session.observeJob`. + */ + readonly outputTotal?: number } /** Complete live control baseline emitted once per control stream generation. */ @@ -493,6 +499,63 @@ export type SessionControlFrame = | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] } | ({ readonly type: 'projection' } & SessionProjectionUpdate) +/** Opens one observation stream over a job's retained record output. */ +export interface SessionObserveJobRequest { + /** Owning session used for the fenced read; omitted for an unowned job. */ + readonly sessionId?: SessionId + readonly jobId: JobId + /** + * Absolute byte offset to resume from — a previous frame's `next`. Omitted + * starts at the oldest retained byte. + */ + readonly from?: number +} + +/** One retained record chunk on the wire. `channel` widens to string like `kind`. */ +export interface SessionJobWireChunk { + /** Absolute offset of the chunk's first byte. */ + readonly at: number + readonly text: string + readonly channel?: string + /** Bytes immediately before this chunk were lost at the producer or to retention. */ + readonly gapBefore?: true +} + +/** + * Job observation stream frames: one `opened` anchor, then coalesced `output` + * batches, then — once the job has settled and its retained record is + * drained — one terminal `status`, after which the stream closes normally. + * Status rides the same stream as output so settlement can never race a + * still-open output channel. + */ +export type SessionObserveJobFrame = + | { + readonly type: 'opened' + readonly jobId: JobId + /** Offset the first `output` frame continues from. */ + readonly from: number + /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ + readonly earliest: number + /** Total bytes produced at open time. */ + readonly total: number + readonly status: SessionJob['status'] + readonly detail?: string + } + | { + readonly type: 'output' + readonly chunks: readonly SessionJobWireChunk[] + /** Offset to resume from after this frame. */ + readonly next: number + /** Bytes between the requested offset and `chunks` were already evicted. */ + readonly lossy?: true + } + | { + readonly type: 'status' + readonly status: SessionJob['status'] + readonly detail?: string + readonly finishedAt?: number + } + declare module '@deepseek-ai/cordis' { interface Events { /** diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts index f827573d03..61a9a8156a 100644 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -162,6 +162,25 @@ describe('Session control jobs updates', () => { ]) }) + it('carries outputTotal exactly for record-declaring jobs', async () => { + const { ctx, agent, control } = await harness(true) + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 1, abort) + ctx.jobs.start({ + kind: 'bash', + label: 'observable run', + owner: agent, + record: true, + run: (job) => { + job.append('hi') + return { cancel: () => {}, done: new Promise(() => {}) } + }, + }) + + const [frame] = await collected + expect(frame?.jobs[0]?.outputTotal).toBe(2) + }) + it('fans an unowned change out to every attached session', async () => { const { ctx, control } = await harness(true) const second = ctx.sessions.create() diff --git a/packages/api/session-controller/tests/job-output.client.spec.ts b/packages/api/session-controller/tests/job-output.client.spec.ts new file mode 100644 index 0000000000..a4ca2933dc --- /dev/null +++ b/packages/api/session-controller/tests/job-output.client.spec.ts @@ -0,0 +1,413 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { ClientJobOutput, ClientJobOutputModel } from '../src/client/job-output.ts' +import type { SessionObserveJobFrame } from '../src/types.ts' + +const ID = 'bash-1' as JobId + +function opened(model: ClientJobOutputModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { + model.observeOpened(ID, { + type: 'opened', + jobId: ID, + from: 0, + earliest: 0, + total: 0, + status: 'running', + ...over, + }) +} + +describe('ClientJobOutputModel observation', () => { + it('accumulates output, advances the resume cursor, and settles on status', () => { + const model = new ClientJobOutputModel() + opened(model) + expect(model.cursorOf(ID)).toBe(0) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 }) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 }) + expect(model.cursorOf(ID)).toBe(3) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text).toBe('abc') + expect(view?.streaming).toBe(true) + model.observeStatus(ID, { type: 'status', status: 'completed', detail: 'exit code: 0' }) + const settled = model.getSnapshot().observed[String(ID)] + expect(settled?.streaming).toBe(false) + expect(settled?.status).toBe('completed') + expect(settled?.detail).toBe('exit code: 0') + }) + + it('keeps snapshot identity stable between changes and notifies subscribers', () => { + const model = new ClientJobOutputModel() + let notified = 0 + const unsubscribe = model.subscribe(() => { notified += 1 }) + const before = model.getSnapshot() + expect(model.getSnapshot()).toBe(before) + opened(model) + expect(notified).toBe(1) + expect(model.getSnapshot()).not.toBe(before) + unsubscribe() + model.observeStopped(ID) + expect(notified).toBe(1) + }) + + it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => { + const model = new ClientJobOutputModel() + opened(model, { from: 4, earliest: 8, total: 10 }) + expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const clean = new ClientJobOutputModel() + opened(clean) + clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 }) + expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const lossy = new ClientJobOutputModel() + opened(lossy) + lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true }) + expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + + it('marks a fresh observation anchored past the evicted head', () => { + // Fresh observations anchor at the registry's earliest retained byte, so + // from === earliest > 0 means the head was already discarded. + const fresh = new ClientJobOutputModel() + opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 }) + expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + // A retry that accumulated no text yet earns the mark the same way. + const retried = new ClientJobOutputModel() + opened(retried) + expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false) + opened(retried, { from: 6, earliest: 6, total: 6 }) + expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + + it('bounds the render tail without splitting a surrogate pair', () => { + const model = new ClientJobOutputModel() + opened(model) + const emoji = '😀'.repeat((64 * 1024) + 8) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.gapBefore).toBe(true) + expect(view!.text.length).toBeLessThanOrEqual(128 * 1024) + // The bound landed between pairs: the surviving text still round-trips. + expect(/^(?:😀)+$/u.test(view!.text)).toBe(true) + }) + + it('carries the anchor detail and trims a plain-ASCII tail without a boundary shift', () => { + const model = new ClientJobOutputModel() + model.observeOpened(ID, { + type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running', detail: 'exit soon', + }) + expect(model.getSnapshot().observed[String(ID)]?.detail).toBe('exit soon') + const long = 'x'.repeat((128 * 1024) + 5) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text.length).toBe(128 * 1024) + expect(view?.gapBefore).toBe(true) + }) + + it('advances the cut past a low surrogate landing exactly on the bound', () => { + const model = new ClientJobOutputModel() + opened(model) + // 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index. + const text = 'z😀' + 'a'.repeat((128 * 1024) - 1) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text.length).toBe((128 * 1024) - 1) + expect(view?.text.startsWith('a')).toBe(true) + }) + + it('preserves accumulated text across a reconnect anchor and clears on stop', () => { + const model = new ClientJobOutputModel() + opened(model) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 }) + opened(model, { from: 4, earliest: 0, total: 4 }) + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('kept') + model.observeStopped(ID) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + expect(model.cursorOf(ID)).toBeUndefined() + // A second stop is inert. + model.observeStopped(ID) + }) + + it('records a terminal stream failure on the live view', () => { + const model = new ClientJobOutputModel() + opened(model) + model.observeFailed(ID, new Error('carrier gone')) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.streaming).toBe(false) + expect(view?.error).toContain('carrier gone') + // A failure for an untracked id is inert. + model.observeFailed('bash-9' as JobId, new Error('ignored')) + }) +}) + +/** One scripted logical stream: frames are pushed by the test, never reopened. */ +class FakeStream { + disposed = false + /** Model a carrier whose disposal surfaces as an iterator throw. */ + throwOnDispose = false + /** Hold the dispose promise open until {@link releaseDispose}. */ + deferDispose = false + private disposeRelease: (() => void) | undefined + private disposePending: Promise | undefined + private readonly frames: SessionObserveJobFrame[] = [] + private waiter: (() => void) | undefined + private failure: Error | undefined + readonly accepts: number[] = [] + + push(frame: SessionObserveJobFrame): void { + this.frames.push(frame) + this.waiter?.() + } + + poison(error: Error): void { + this.failure = error + this.waiter?.() + } + + dispose(): Promise { + if (this.throwOnDispose && this.failure === undefined) { + this.failure = new Error('carrier tore down') + } else { + this.disposed = true + } + this.waiter?.() + if (this.deferDispose) { + // Repeat disposals (releaser plus the consumer's finally) share one + // deferred promise, so releaseDispose resumes every waiter. + this.disposePending ??= new Promise((resolve) => { this.disposeRelease = resolve }) + return this.disposePending + } + return Promise.resolve() + } + + releaseDispose(): void { + this.disposeRelease?.() + } + + async *[Symbol.asyncIterator]() { + let generation = 1 + while (!this.disposed) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame === undefined) { + await new Promise((resolve) => { this.waiter = resolve }) + continue + } + yield { + generation, + value: frame, + signal: new AbortController().signal, + accept: () => { this.accepts.push(generation) }, + } + generation = 1 + } + } +} + +function bench() { + const ctx = new Context() + const model = new ClientJobOutputModel() + const streams: { options: { open: (signal: AbortSignal) => unknown }; stream: FakeStream }[] = [] + const observeCalls: unknown[] = [] + const remote = { + $stream: (options: { open: (signal: AbortSignal) => unknown }) => { + const stream = new FakeStream() + streams.push({ options, stream }) + return stream + }, + session: { + observeJob: (request: unknown) => { + observeCalls.push(request) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, + }, + } + const output = new ClientJobOutput(ctx, remote as never, model) + return { ctx, model, output, streams, observeCalls } +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +describe('ClientJobOutput observation streams', () => { + it('feeds anchor, output, and terminal frames into the model, then closes', async () => { + const { model, output, streams } = bench() + output.observe(undefined, ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi') + expect(stream.accepts).toHaveLength(1) + + stream.push({ type: 'status', status: 'completed' }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) + expect(stream.disposed).toBe(true) + }) + + it('shares one stream across observers and stops after the last release', async () => { + const { model, output, streams } = bench() + const first = output.observe(undefined, ID) + const second = output.observe(undefined, ID) + expect(streams).toHaveLength(1) + streams[0]!.stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + + first() + first() + expect(streams[0]!.stream.disposed).toBe(false) + second() + await tick() + expect(streams[0]!.stream.disposed).toBe(true) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + }) + + it('a late release of a superseded observation neither tears down nor clears its successor', async () => { + const { model, output, streams } = bench() + const firstStop = output.observe(undefined, ID) + const first = streams[0]!.stream + first.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + first.push({ type: 'status', status: 'completed' }) + await tick() + // A successor observation replaces the settled entry while the first + // observer still holds its releaser. + const secondStop = output.observe(undefined, ID) + const second = streams[1]!.stream + second.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + + firstStop() + await tick() + expect(second.disposed).toBe(false) + expect(model.getSnapshot().observed[String(ID)]).toBeDefined() + + secondStop() + await tick() + expect(second.disposed).toBe(true) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + }) + + it('a re-observation inside the dispose round-trip keeps its fresh view', async () => { + const { model, output, streams } = bench() + const stop = output.observe(undefined, ID) + const first = streams[0]!.stream + first.deferDispose = true + first.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + + stop() + // Re-expand while the previous generation's dispose is still in flight. + output.observe(undefined, ID) + const second = streams[1]!.stream + second.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + + first.releaseDispose() + await tick() + // The stale post-dispose clear must not blank the successor's view. + expect(model.getSnapshot().observed[String(ID)]).toBeDefined() + second.push({ type: 'output', chunks: [{ at: 0, text: 'alive' }], next: 5 }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('alive') + }) + + it('opens a fresh stream for a re-observed job and resumes from the model cursor', async () => { + const { output, streams, observeCalls, model } = bench() + const stop = output.observe('alice' as SessionId, ID) + streams[0]!.stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 }) + await tick() + // The generation opener reads the live cursor at open time and carries the + // fenced-read session. + streams[0]!.options.open(new AbortController().signal) + expect(observeCalls.at(-1)).toEqual({ jobId: ID, sessionId: 'alice', from: 3 }) + + stop() + await tick() + output.observe(undefined, ID) + expect(streams).toHaveLength(2) + streams[1]!.options.open(new AbortController().signal) + // Stopped observation dropped the cursor: the fresh stream starts unanchored. + expect(observeCalls.at(-1)).toEqual({ jobId: ID }) + expect(model.cursorOf(ID)).toBeUndefined() + }) + + it('classifies a premature end as retryable only after the anchor', () => { + const { output, streams } = bench() + output.observe(undefined, ID) + const options = streams[0]!.options as unknown as { ended: (accepted: boolean) => Error } + expect(options.ended(true).name).toBe('RemoteStreamCarrierError') + expect(options.ended(false).name).toBe('Error') + }) + + it('records a consumer failure on the live view', async () => { + const { model, output, streams } = bench() + output.observe(undefined, ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + stream.poison(new Error('frame decode broke')) + await tick() + const view = model.getSnapshot().observed[String(ID)] + expect(view?.error).toContain('frame decode broke') + expect(view?.streaming).toBe(false) + }) + + it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => { + const { ctx, model, output, streams } = bench() + const stop = output.observe(undefined, ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + // Poison wakes the consumer, but the synchronous stop lands first, so the + // failure arrives on an already-stopped entry and stays silent. + stream.poison(new Error('after stop')) + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + + const second = output.observe(undefined, ID) + await ctx.fiber.dispose() + // The disposal path already removed every entry; a live releaser whose + // entry is gone is inert. + second() + }) + + it('stays silent when the carrier teardown itself throws after a stop', async () => { + const { model, output, streams } = bench() + const stop = output.observe(undefined, ID) + const { stream } = streams[0]! + stream.throwOnDispose = true + stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + // The releaser marks the entry stopped, then its dispose surfaces as an + // iterator throw — which the consumer swallows for a stopped entry. + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined() + }) + + it('records a terminal stream failure and service disposal closes live streams', async () => { + const { ctx, model, output, streams } = bench() + output.observe(undefined, ID) + const failing = streams[0]!.stream + failing.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + const iterator = failing[Symbol.asyncIterator]() + void iterator + // Simulate a terminal consumer failure by disposing the underlying stream: + // the consumer loop ends without a status frame and cleans up. + const spy = vi.spyOn(model, 'observeFailed') + await failing.dispose() + await tick() + + const other = 'bash-2' as JobId + output.observe(undefined, other) + await ctx.fiber.dispose() + expect(streams.every(entry => entry.stream.disposed)).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api/session-controller/tests/observe-job.host.spec.ts b/packages/api/session-controller/tests/observe-job.host.spec.ts new file mode 100644 index 0000000000..b61079d2b1 --- /dev/null +++ b/packages/api/session-controller/tests/observe-job.host.spec.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { JobId } from '@deepseek-ai/dsh-jobs' +import type { JobOutcome, RunningJob } from '@deepseek-ai/dsh-jobs' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import { observeJobRecord } from '../src/observe-job.ts' +import type { ObserveJobOptions } from '../src/observe-job.ts' +import type { SessionObserveJobFrame } from '../src/types.ts' +import { createSessionTestController } from './test-remote.ts' + +async function harness() { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry, { retainBytes: 64, settledRetainBytes: 64 }) + ctx.jobs.attachController('observe-job-test') + return ctx +} + +function registerAgent(ctx: Context, rawId: string): Agent { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(rawId) + const session = Session.create(id) + const agent = { + id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scopeFiber.ctx, + } as unknown as Agent + ctx.agents.register(agent) + return agent +} + +/** Start one record job whose done settles on demand; expose the producer face. */ +function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; record?: boolean } = {}) { + let settle!: (outcome: JobOutcome) => void + let face!: RunningJob + const id = ctx.jobs.start({ + kind: 'bash', + label: options.label ?? 'echo', + ...options.owner !== undefined ? { owner: options.owner } : {}, + ...options.record === false ? {} : { record: true }, + run: (job) => { + face = job + return { + cancel() {}, + done: new Promise((resolve) => { settle = resolve }), + } + }, + }) + return { + id, + append: (text: string, opts?: Parameters[1]) => { face.append(text, opts) }, + settle: async (outcome: JobOutcome) => { + settle(outcome) + await new Promise(resolve => setTimeout(resolve, 0)) + }, + } +} + +/** Read frames until `count` arrive or the generator finishes, then abort. */ +async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: Frame[] = [] + const iterator = iterable[Symbol.asyncIterator]() + try { + while (frames.length < count) { + const step = await iterator.next() + if (step.done) return frames + frames.push(step.value) + } + } finally { + abort.abort() + await iterator.next().catch(() => undefined) + } + return frames +} + +const OBSERVE: Omit = { flushMs: 5, maxFrameBytes: 1024 } + +describe('observeJobRecord', () => { + function observed(ctx: Context, request: { jobId: JobId; from?: number }, caller?: Agent) { + const abort = new AbortController() + const frames: SessionObserveJobFrame[] = [] + const stream = observeJobRecord(ctx.jobs, request, { ...OBSERVE, caller }, abort.signal) + const done = (async () => { + for await (const frame of stream) frames.push(frame) + })() + return { abort, frames, done } + } + + it('anchors, streams coalesced output, and closes with the terminal status', async () => { + const ctx = await harness() + const job = startRecordJob(ctx) + job.append('early ') + const { frames, done } = observed(ctx, { jobId: job.id }) + await new Promise(resolve => setTimeout(resolve, 20)) + job.append('live', { channel: 'stderr' }) + await new Promise(resolve => setTimeout(resolve, 20)) + await job.settle({ status: 'completed', detail: 'exit code: 0' }) + await done + + expect(frames[0]).toMatchObject({ type: 'opened', from: 0, earliest: 0, total: 6, status: 'running' }) + const outputs = frames.filter(frame => frame.type === 'output') + const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') + expect(text).toBe('early live') + expect(outputs.at(-1)?.next).toBe(10) + expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr') + expect(frames.at(-1)).toMatchObject({ type: 'status', status: 'completed', detail: 'exit code: 0' }) + }) + + it('resumes from an explicit offset and flags an evicted resume lossy', async () => { + const ctx = await harness() + const job = startRecordJob(ctx, { label: 'spam' }) + job.append('a'.repeat(60)) + job.append('b'.repeat(60)) + await job.settle({ status: 'completed' }) + + const fresh = observed(ctx, { jobId: job.id, from: 0 }) + await fresh.done + const output = fresh.frames.find(frame => frame.type === 'output') + expect(output?.lossy).toBe(true) + expect(output?.chunks.map(chunk => chunk.text).join('')).toBe('b'.repeat(60)) + + const resumed = observed(ctx, { jobId: job.id, from: 120 }) + await resumed.done + expect(resumed.frames.some(frame => frame.type === 'output')).toBe(false) + expect(resumed.frames.at(-1)?.type).toBe('status') + }) + + it('splits oversized reads along the frame budget', async () => { + const ctx = await harness() + const job = startRecordJob(ctx, { label: 'wide' }) + job.append('x'.repeat(30)) + job.append('y'.repeat(30)) + await job.settle({ status: 'completed' }) + const abort = new AbortController() + const frames = await collect( + observeJobRecord(ctx.jobs, { jobId: job.id }, { + flushMs: 5, + maxFrameBytes: 30, + caller: undefined, + }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs).toHaveLength(2) + expect(outputs[0]?.next).toBe(30) + expect(outputs[1]?.next).toBe(60) + }) + + it('carries the lossy flag on the first frame of a split evicted read', async () => { + const ctx = await harness() + const job = startRecordJob(ctx, { label: 'evicted split' }) + job.append('a'.repeat(40)) + job.append('b'.repeat(40)) + job.append('c'.repeat(40)) + await job.settle({ status: 'completed' }) + const abort = new AbortController() + const frames = await collect( + observeJobRecord(ctx.jobs, { jobId: job.id, from: 0 }, { + flushMs: 5, + maxFrameBytes: 40, + caller: undefined, + }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs[0]?.lossy).toBe(true) + expect(outputs[1]?.lossy).toBeUndefined() + }) + + it('reads owned records through the resolved caller and rejects foreign, bad, and unknown input', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + const job = startRecordJob(ctx, { label: 'secret', owner }) + job.append('classified') + await job.settle({ status: 'completed' }) + + const { frames, done } = observed(ctx, { jobId: job.id }, owner) + await done + expect(frames.some(frame => frame.type === 'output' + && frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true) + + const foreign = new AbortController() + await expect(collect( + observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, foreign.signal), + 1, + foreign, + )).rejects.toThrow(/belongs to another session/) + + const abort = new AbortController() + await expect(collect( + observeJobRecord(ctx.jobs, { jobId: job.id, from: -1 }, { ...OBSERVE, caller: owner }, abort.signal), + 1, + abort, + )).rejects.toThrow(/invalid observe offset/) + + const abortUnknown = new AbortController() + await expect(collect( + observeJobRecord(ctx.jobs, { jobId: JobId('bash-999') }, { ...OBSERVE, caller: undefined }, abortUnknown.signal), + 1, + abortUnknown, + )).rejects.toThrow(/unknown job/) + }) + + it('refuses a job that declared no record', async () => { + const ctx = await harness() + const job = startRecordJob(ctx, { record: false }) + const abort = new AbortController() + await expect(collect( + observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, abort.signal), + 1, + abort, + )).rejects.toThrow(/declared no output record/) + await job.settle({ status: 'completed' }) + }) + + it('ignores output signals for other jobs while waiting', async () => { + const ctx = await harness() + const watched = startRecordJob(ctx, { label: 'watched' }) + const noisy = startRecordJob(ctx, { label: 'noisy' }) + const { abort, frames, done } = observed(ctx, { jobId: watched.id }) + await new Promise(resolve => setTimeout(resolve, 15)) + noisy.append('unrelated') + await new Promise(resolve => setTimeout(resolve, 15)) + await watched.settle({ status: 'completed' }) + await done + abort.abort() + expect(frames.filter(frame => frame.type === 'output')).toEqual([]) + await noisy.settle({ status: 'completed' }) + }) + + it('stops cleanly on abort while waiting for output', async () => { + const ctx = await harness() + const job = startRecordJob(ctx, { label: 'idle' }) + const { abort, frames, done } = observed(ctx, { jobId: job.id }) + await new Promise(resolve => setTimeout(resolve, 20)) + abort.abort() + await done + expect(frames[0]?.type).toBe('opened') + expect(frames.at(-1)?.type).not.toBe('status') + await job.settle({ status: 'completed' }) + }) +}) + +describe('SessionController.observeJob', () => { + const defaults = { + defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + cwd: '/tmp', + } + + it('refuses observation without a job registry', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const controller = createSessionTestController(ctx, defaults) + expect(() => controller.observeJob({ jobId: JobId('bash-1') }, new AbortController().signal)) + .toThrow(/job observation unavailable/) + }) + + it('resolves the fenced-read caller from the request session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + ctx.jobs.attachController('observe-job-test') + const controller = createSessionTestController(ctx, defaults) + const session = ctx.sessions.create(SessionId('observing-session')) + const owner = { + id: session.id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx, + } as unknown as Agent + ctx.agents.register(owner) + const job = startRecordJob(ctx, { label: 'owned run', owner }) + job.append('hi') + await job.settle({ status: 'completed' }) + + const abort = new AbortController() + const frames = await collect( + controller.observeJob({ sessionId: session.id, jobId: job.id }, abort.signal), + 3, + abort, + ) + expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + }) +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 3d7d549a26..d125b6808d 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -34,6 +34,8 @@ import type { SessionFollowRequest, SessionListRequest, SessionListValue, + SessionObserveJobFrame, + SessionObserveJobRequest, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionPage, @@ -71,6 +73,7 @@ export interface TestSessionRemote { page(request: SessionPageRequest, signal?: AbortSignal): Promise> follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable control(signal?: AbortSignal): AsyncIterable + observeJob(request: SessionObserveJobRequest, signal?: AbortSignal): AsyncIterable } /** Dependencies and policy supplied by a Session Controller unit harness. */ @@ -273,5 +276,6 @@ export function createSessionTestRemote( ), follow: (request, signal = new AbortController().signal) => direct.follow(request, signal), control: (signal = new AbortController().signal) => direct.control(signal), + observeJob: (request, signal = new AbortController().signal) => direct.observeJob(request, signal), } } diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index a48fcb83a5..207daafbd5 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -18,6 +18,7 @@ "src/history.ts", "src/list.ts", "src/model-selection-projection.ts", + "src/observe-job.ts", "src/skill-catalog.ts" ], "references": [ From 0dca00b425a0d375756701c400a1cf6c8447d616 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 18:22:04 +0800 Subject: [PATCH 016/141] refactor(api,client)!: delete the activity plane; one job roster with record observation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/activity/* and packages/api/activity-controller are removed. The web roster is the session control stream's jobs frames alone; per-job output observation rides session.observeJob and the client ctx.jobOutput service. ui-activity returns to its upstream name ui-jobs and renders the single roster — a row is expandable exactly when its job declared a record (outputTotal present). Composition rows, dependency-policy exemptions, tsconfig aliases and references, generated catalogs, and the docs pages follow; the activity subsystem page is deleted and the jobs page gains the record contract. The superseding Agent Note records the decision and points the three prior notes at it. --- .../2026-09-01-jobs-absorb-activity-record.md | 35 ++ ...26-09-01-jobs-absorb-activity-record.zh.md | 35 ++ .../2026-08-08-web-background-job-display.md | 2 + ...026-08-08-web-background-job-display.zh.md | 2 + .../2026-08-24-activity-observation-seam.md | 2 + ...2026-08-24-activity-observation-seam.zh.md | 2 + .../feature/2026-08-25-unified-task-list.md | 2 + .../2026-08-25-unified-task-list.zh.md | 2 + apps/cli/composition.md | 3 - ...y-stream.e2e.ts => live-job-stream.e2e.ts} | 26 +- .../settled.expected.md | 0 .../streaming.expected.md | 0 apps/web/tsconfig.json | 2 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 20 +- docs/config-catalog.md | 63 +-- docs/event-producer-consumer.md | 14 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 41 +- docs/module-graph.zh.md | 41 +- docs/subsystems/README.md | 1 - docs/subsystems/README.zh.md | 1 - docs/subsystems/activity.md | 141 ------ docs/subsystems/activity.zh.md | 141 ------ docs/subsystems/jobs.md | 141 +++++- docs/subsystems/jobs.zh.md | 27 ++ docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 12 + docs/subsystems/session.zh.md | 12 + packages/activity/README.i18n.yaml | 6 - packages/activity/README.md | 43 -- packages/activity/README.zh.md | 43 -- .../activity/activity-local/README.i18n.yaml | 6 - packages/activity/activity-local/README.md | 98 ---- packages/activity/activity-local/README.zh.md | 98 ---- packages/activity/activity-local/package.json | 55 --- packages/activity/activity-local/src/index.ts | 438 ------------------ .../activity/activity-local/src/invariant.ts | 33 -- .../activity-local/tests/activity.spec.ts | 413 ----------------- .../tests/loader-composition.spec.ts | 60 --- .../activity/activity-local/tsconfig.json | 33 -- packages/activity/activity/README.i18n.yaml | 6 - packages/activity/activity/README.md | 101 ---- packages/activity/activity/README.zh.md | 101 ---- packages/activity/activity/package.json | 57 --- packages/activity/activity/src/brand.ts | 28 -- packages/activity/activity/src/index.ts | 138 ------ packages/activity/activity/src/invariant.ts | 62 --- packages/activity/activity/src/pump.ts | 95 ---- packages/activity/activity/src/types.ts | 184 -------- .../activity/activity/tests/invariant.spec.ts | 82 ---- .../activity/activity/tests/service.spec.ts | 98 ---- packages/activity/activity/tsconfig.json | 36 -- .../api/activity-controller/README.i18n.yaml | 6 - packages/api/activity-controller/README.md | 106 ----- packages/api/activity-controller/README.zh.md | 106 ----- packages/api/activity-controller/package.json | 103 ---- .../activity-controller/src/client/index.ts | 86 ---- .../activity-controller/src/client/model.ts | 216 --------- .../activity-controller/src/client/service.ts | 164 ------- packages/api/activity-controller/src/feed.ts | 184 -------- packages/api/activity-controller/src/index.ts | 104 ----- .../api/activity-controller/src/invariant.ts | 23 - .../api/activity-controller/src/observe.ts | 178 ------- packages/api/activity-controller/src/types.ts | 104 ----- .../tests/activity-controller.host.spec.ts | 306 ------------ .../tests/client-apply.client.spec.ts | 113 ----- .../tests/client-model.client.spec.ts | 183 -------- .../tests/client-service.client.spec.ts | 276 ----------- .../tests/controller.host.spec.ts | 102 ---- .../activity-controller/tsconfig.client.json | 46 -- .../activity-controller/tsconfig.host.json | 26 -- .../api/activity-controller/tsconfig.json | 7 - .../api/activity-controller/tsdown.config.ts | 7 - packages/api/remotes/package.json | 1 - packages/api/remotes/src/client/index.ts | 5 +- packages/api/remotes/src/index.ts | 1 - packages/api/remotes/tsconfig.client.json | 3 - packages/api/remotes/tsconfig.host.json | 3 - packages/api/session-controller/README.md | 4 + packages/bundle/base/cordis.patch.yml | 5 - packages/bundle/base/package.json | 1 - packages/bundle/web-app/cordis.patch.yml | 12 +- packages/bundle/web-app/package.json | 3 +- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- packages/client/ui-activity/README.md | 93 ---- packages/client/ui-activity/README.zh.md | 93 ---- .../client/ui-activity/src/client/index.ts | 50 -- .../{ui-activity => ui-jobs}/README.i18n.yaml | 6 +- packages/client/ui-jobs/README.md | 93 ++++ packages/client/ui-jobs/README.zh.md | 93 ++++ .../{ui-activity => ui-jobs}/package.json | 10 +- .../src/client/JobListAction.module.css} | 0 .../src/client/JobListAction.tsx} | 222 +++------ packages/client/ui-jobs/src/client/index.ts | 49 ++ .../src/client/locales.ts | 30 +- .../src/css-modules.d.ts | 0 .../{ui-activity => ui-jobs}/src/index.ts | 4 +- .../{ui-activity => ui-jobs}/src/invariant.ts | 10 +- .../tests/browser-plugin.client.spec.ts | 50 +- .../tests/job-list-action.client.spec.tsx} | 274 ++++------- .../{ui-activity => ui-jobs}/tsconfig.json | 1 - .../{ui-activity => ui-jobs}/tsdown.config.ts | 2 +- .../src/client/slot-catalog.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 212 +++------ pnpm-lock.yaml | 218 ++------- python/sdk-runtime/package.json | 1 - scripts/gen-cordis-catalog.ts | 22 +- scripts/gen-doc-graphs.ts | 20 +- scripts/package-dependency-policy.ts | 1 - scripts/verify-package-dependencies.spec.ts | 1 - .../verify-package-readme-model-experience.ts | 5 +- tsconfig.base.json | 14 +- tsconfig.client.json | 3 +- tsconfig.host.json | 5 +- 117 files changed, 923 insertions(+), 6243 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md create mode 100644 .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md rename apps/web/tests/{live-activity-stream.e2e.ts => live-job-stream.e2e.ts} (84%) rename apps/web/tests/snapshots/{live-activity-stream => live-job-stream}/settled.expected.md (100%) rename apps/web/tests/snapshots/{live-activity-stream => live-job-stream}/streaming.expected.md (100%) delete mode 100644 docs/subsystems/activity.md delete mode 100644 docs/subsystems/activity.zh.md delete mode 100644 packages/activity/README.i18n.yaml delete mode 100644 packages/activity/README.md delete mode 100644 packages/activity/README.zh.md delete mode 100644 packages/activity/activity-local/README.i18n.yaml delete mode 100644 packages/activity/activity-local/README.md delete mode 100644 packages/activity/activity-local/README.zh.md delete mode 100644 packages/activity/activity-local/package.json delete mode 100644 packages/activity/activity-local/src/index.ts delete mode 100644 packages/activity/activity-local/src/invariant.ts delete mode 100644 packages/activity/activity-local/tests/activity.spec.ts delete mode 100644 packages/activity/activity-local/tests/loader-composition.spec.ts delete mode 100644 packages/activity/activity-local/tsconfig.json delete mode 100644 packages/activity/activity/README.i18n.yaml delete mode 100644 packages/activity/activity/README.md delete mode 100644 packages/activity/activity/README.zh.md delete mode 100644 packages/activity/activity/package.json delete mode 100644 packages/activity/activity/src/brand.ts delete mode 100644 packages/activity/activity/src/index.ts delete mode 100644 packages/activity/activity/src/invariant.ts delete mode 100644 packages/activity/activity/src/pump.ts delete mode 100644 packages/activity/activity/src/types.ts delete mode 100644 packages/activity/activity/tests/invariant.spec.ts delete mode 100644 packages/activity/activity/tests/service.spec.ts delete mode 100644 packages/activity/activity/tsconfig.json delete mode 100644 packages/api/activity-controller/README.i18n.yaml delete mode 100644 packages/api/activity-controller/README.md delete mode 100644 packages/api/activity-controller/README.zh.md delete mode 100644 packages/api/activity-controller/package.json delete mode 100644 packages/api/activity-controller/src/client/index.ts delete mode 100644 packages/api/activity-controller/src/client/model.ts delete mode 100644 packages/api/activity-controller/src/client/service.ts delete mode 100644 packages/api/activity-controller/src/feed.ts delete mode 100644 packages/api/activity-controller/src/index.ts delete mode 100644 packages/api/activity-controller/src/invariant.ts delete mode 100644 packages/api/activity-controller/src/observe.ts delete mode 100644 packages/api/activity-controller/src/types.ts delete mode 100644 packages/api/activity-controller/tests/activity-controller.host.spec.ts delete mode 100644 packages/api/activity-controller/tests/client-apply.client.spec.ts delete mode 100644 packages/api/activity-controller/tests/client-model.client.spec.ts delete mode 100644 packages/api/activity-controller/tests/client-service.client.spec.ts delete mode 100644 packages/api/activity-controller/tests/controller.host.spec.ts delete mode 100644 packages/api/activity-controller/tsconfig.client.json delete mode 100644 packages/api/activity-controller/tsconfig.host.json delete mode 100644 packages/api/activity-controller/tsconfig.json delete mode 100644 packages/api/activity-controller/tsdown.config.ts delete mode 100644 packages/client/ui-activity/README.md delete mode 100644 packages/client/ui-activity/README.zh.md delete mode 100644 packages/client/ui-activity/src/client/index.ts rename packages/client/{ui-activity => ui-jobs}/README.i18n.yaml (70%) create mode 100644 packages/client/ui-jobs/README.md create mode 100644 packages/client/ui-jobs/README.zh.md rename packages/client/{ui-activity => ui-jobs}/package.json (83%) rename packages/client/{ui-activity/src/client/ActivityListAction.module.css => ui-jobs/src/client/JobListAction.module.css} (100%) rename packages/client/{ui-activity/src/client/ActivityListAction.tsx => ui-jobs/src/client/JobListAction.tsx} (61%) create mode 100644 packages/client/ui-jobs/src/client/index.ts rename packages/client/{ui-activity => ui-jobs}/src/client/locales.ts (78%) rename packages/client/{ui-activity => ui-jobs}/src/css-modules.d.ts (100%) rename packages/client/{ui-activity => ui-jobs}/src/index.ts (66%) rename packages/client/{ui-activity => ui-jobs}/src/invariant.ts (70%) rename packages/client/{ui-activity => ui-jobs}/tests/browser-plugin.client.spec.ts (73%) rename packages/client/{ui-activity/tests/activity-list-action.client.spec.tsx => ui-jobs/tests/job-list-action.client.spec.tsx} (59%) rename packages/client/{ui-activity => ui-jobs}/tsconfig.json (89%) rename packages/client/{ui-activity => ui-jobs}/tsdown.config.ts (76%) diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md new file mode 100644 index 0000000000..501cdcf801 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -0,0 +1,35 @@ +# Agent Note: The job registry absorbs the observation record; the standalone activity seam is removed + +Status: implemented + +English | [中文](2026-09-01-jobs-absorb-activity-record.zh.md) + +## Problem + +The [activity observation seam](../feature/2026-08-24-activity-observation-seam.md) shipped live output streaming as a second registry beside `ctx.jobs`: producers registered the same work twice (a job for lifecycle, an activity for observation), kept the two terminal states consistent by hand (`observeBackgroundActivity` waited for `proc.done` before mapping the outcome so a pump failure could not freeze a wrong terminal state), correlated the rows through `ActivityCorrelation.jobId`, and wrapped every observation call in registry-absent degradation branches. The Web client maintained two rosters (session-control `jobs` frames and the activity control stream) and joined them per row. Measured before the merge: eight pairing call sites, and one producer population — background bash/pwsh (both registries), PTY sends and subagent delegations (jobs only), foreground workflow (activity only). + +The one activity without a job was the foreground workflow mirror. Everything else the split enabled — independent observers, absolute-offset reads, bounded retention — is a property of the record, not of the registry split. + +## Decision + +`ctx.jobs` owns the observation record; `packages/activity/`, `packages/api/activity-controller`, and the correlation vocabulary are removed. + +- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops. +- **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented. +- **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design. +- **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it. +- **The wire moves to the session namespace.** `SessionJob.outputTotal` (present exactly for record jobs) marks a row observable; `session.observeJob({sessionId?, jobId, from?})` streams anchor/output/status frames with the fenced read resolved from the request's session — the killJob authorization shape. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery moved into `session-controller` (`observe-job.ts`, client `ctx.jobOutput`). +- **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `outputTotal` is present. The two-roster join is deleted. +- **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows. + +## Rejected + +- **A `foreground: true` job mode** to keep the foreground workflow panel: it needs two coupled enforcement points (reported-at-birth and a `job_list` filter) whose divergence double-delivers or leaks rows to the model, for one edge feature replaceable by `run_in_background`. +- **Serving model reads from the record** (deleting `readOutput`): model reads are producer-formatted (truncation and spill notices, sandbox markers) and consuming; the record is raw, channel-labeled, and non-consuming. Unifying them moves producer-specific formatting into the registry or noise into the observer stream. +- **Keeping the split with shared implementation** (a common registry library): it removes the duplicated skeleton but keeps the real costs — double registration, terminal-state pairing, correlation, a second wire roster, and a second package family. + +The [prior seam note](../feature/2026-08-24-activity-observation-seam.md)'s argument against durable session events and control-stream framing for live output still holds and carries over unchanged: the record is process-local observation state, never a session event, so "model-visible ⟺ logged" is untouched. + +## Consequences + +Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `session-controller`. diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md new file mode 100644 index 0000000000..40e795146c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -0,0 +1,35 @@ +# Agent Note:job 注册表吸收观测 record;独立的 activity seam 被移除 + +Status: implemented + +[English](2026-09-01-jobs-absorb-activity-record.md) | 中文 + +## 问题 + +[activity 观测 seam](../feature/2026-08-24-activity-observation-seam.zh.md) 把实时输出流做成了 `ctx.jobs` 旁边的第二个注册表:生产者要把同一份工作登记两次(job 管生命周期,activity 管观测),手工保持两个终态一致(`observeBackgroundActivity` 要等 `proc.done` 才映射 outcome,防止 pump 失败把错误终态冻上去),通过 `ActivityCorrelation.jobId` 关联两行,并给每个观测调用包上注册表缺失的降级分支。Web 客户端维护两份名册(session-control 的 `jobs` 帧与 activity control 流)并逐行做 join。合并前实测:八个配对调用点;生产者人口为——后台 bash/pwsh(双注册表)、PTY 发送与 subagent 委托(仅 jobs)、前台 workflow(仅 activity)。 + +唯一没有 job 的 activity 是前台 workflow 镜像。拆分带来的其他一切——独立观察者、绝对偏移读取、有界保留——都是 record 的性质,不是注册表拆分的性质。 + +## 决策 + +`ctx.jobs` 拥有观测 record;`packages/activity/`、`packages/api/activity-controller` 与 correlation 词表被移除。 + +- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。 +- **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。 +- **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。 +- **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。 +- **wire 移入 session 命名空间。** `SessionJob.outputTotal`(恰在 record job 上存在)标记行可观测;`session.observeJob({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析——与 killJob 相同的授权形态。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制移入 `session-controller`(`observe-job.ts`,客户端 `ctx.jobOutput`)。 +- **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `outputTotal` 存在时可展开。双名册 join 被删除。 +- **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。 + +## 已否决 + +- **`foreground: true` job 模式**以保留前台 workflow 面板:它需要两个耦合的执行点(出生即 reported 与 `job_list` 过滤),两者一旦分叉就会双投递或向模型泄漏行,而它换来的只是一个可被 `run_in_background` 替代的边缘特性。 +- **用 record 服务模型读取**(删除 `readOutput`):模型读取是生产者格式化的(截断与 spill 提示、sandbox 标记)且消耗型;record 是原始、带 channel 标签、非消耗的。统一二者要么把生产者特有格式化搬进注册表,要么把噪声混入观察者流。 +- **保留拆分但共享实现**(公共注册表库):它消除重复骨架,但保留真正的成本——双重登记、终态配对、correlation、第二条 wire 名册与第二个包族。 + +[先前 seam note](../feature/2026-08-24-activity-observation-seam.zh.md) 反对用持久会话事件或 control 流承载实时输出的论证仍然成立并原样沿用:record 是进程本地观测状态,从不是会话事件,「模型可见 ⟺ 已记录」不受影响。 + +## 后果 + +每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `session-controller`。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 23074b5d38..3dd003c0e9 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -2,6 +2,8 @@ Status: implemented +Update: the output phase this note deferred now ships as the per-job observation record on `ctx.jobs` — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). + English | [中文](2026-08-08-web-background-job-display.zh.md) ## Problem diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 06645267ee..5d1ee4bada 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -2,6 +2,8 @@ Status: implemented +Update:本文推迟的输出阶段现已作为 `ctx.jobs` 上的逐 job 观测 record 交付——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 + [English](2026-08-08-web-background-job-display.md) | 中文 ## 问题 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md index 88109cb340..29ee08b1bb 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md @@ -2,6 +2,8 @@ Status: implemented +Superseded: the standalone seam described here was folded into `ctx.jobs` as the per-job observation record — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md); the durable-vs-live analysis below still holds. + English | [中文](2026-08-24-activity-observation-seam.zh.md) ## Problem diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md index b8aa938edf..97ac4f195c 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md @@ -2,6 +2,8 @@ 状态:implemented +Superseded:本文描述的独立 seam 已折入 `ctx.jobs`,成为逐 job 的观测 record——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md);下文关于持久化与实时通道的分析仍然成立。 + [English](2026-08-24-activity-observation-seam.md) | 中文 ## 问题 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md index e67ca84d96..daf6582bf2 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md @@ -2,6 +2,8 @@ Status: implemented +Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). + English | [中文](2026-08-25-unified-task-list.zh.md) ## Problem diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md index 2a71b04ec3..624747396e 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md @@ -2,6 +2,8 @@ 状态:implemented +Superseded:本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 + [English](2026-08-25-unified-task-list.md) | 中文 ## 问题 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 60ddefee1a..5386b94ae0 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -40,8 +40,6 @@ flowchart LR cfg --> plugin_dsh_base_agent_default_model plugin_dsh_base_jobs["jobs
        @deepseek-ai/dsh-jobs-local"] cfg --> plugin_dsh_base_jobs - plugin_dsh_base_activity["activity
        @deepseek-ai/dsh-activity-local"] - cfg --> plugin_dsh_base_activity plugin_dsh_base_llm_retry["llm-retry
        @deepseek-ai/dsh-llm-retry"] cfg --> plugin_dsh_base_llm_retry plugin_dsh_base_settings["settings
        @deepseek-ai/dsh-settings-file"] @@ -202,7 +200,6 @@ flowchart LR | `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | -| `activity` | `@deepseek-ai/dsh-activity-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | | `settings` | `@deepseek-ai/dsh-settings-file` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | diff --git a/apps/web/tests/live-activity-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts similarity index 84% rename from apps/web/tests/live-activity-stream.e2e.ts rename to apps/web/tests/live-job-stream.e2e.ts index 7c0ce1532b..65926f7463 100644 --- a/apps/web/tests/live-activity-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -1,5 +1,5 @@ -// Session-header live activities driven by a real background bash run: the -// roster row arrives over the activity control stream, expanding it opens the +// Session-header job list driven by a real background bash run: the job row +// arrives over the session control stream, expanding it opens the record // observation stream, and the panel shows the process's real output while it // is still running. No model call is involved. import { readFile } from 'node:fs/promises' @@ -19,11 +19,11 @@ import { import { newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.jsonl', import.meta.url)) -const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-activity-stream', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-job-stream', import.meta.url)) const STREAMING_EXPECTED = join(SNAPSHOT_DIR, 'streaming.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() -const SEED_ID = 'live-activity-stream-web-e2e' +const SEED_ID = 'live-job-stream-web-e2e' // One fixed line of early output, then silence long enough that the streaming // assertions never race the process exiting; the test kills it explicitly. const COMMAND = "printf 'streamed-%s\\n' marker-line; sleep 45" @@ -44,7 +44,7 @@ async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { +describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -69,7 +69,7 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { await sessionRow.click() // Opening the session drives the Host's ordinary Agent resolution; the - // activity owner must be that exact live instance, never a second one. + // job owner must be that exact live instance, never a second one. agent = await liveAgent(scaffold, SessionId(SEED_ID)) }, 120_000) @@ -79,13 +79,13 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { }) it('streams a running background command\'s real output into the expanded panel', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-streaming')) - const trigger = page.getByRole('button', { name: '1 task running' }) + onTestFailed(() => saveFailureShot(page, 'web-e2e-live-job-streaming')) + const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) const started = await scaffold.ctx.tools.execute({ signal: new AbortController().signal, - callId: ToolCallId('live-activity-stream-e2e'), + callId: ToolCallId('live-job-stream-e2e'), name: 'bash', arguments: { command: COMMAND, description: 'Emit one live line then hold', run_in_background: true }, agent, @@ -95,11 +95,11 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) jobId = JobId(matched[0]) - // The roster row reaches the header over the activity control stream. + // The job row reaches the header over the session control stream. await trigger.waitFor({ timeout: 15_000 }) await trigger.click() - // Expanding the row opens the observation stream; the panel then renders + // Expanding the row opens the record observation stream; the panel then renders // the command's real stdout while the process is still running. const expand = page.getByRole('button', { name: /Show live output/ }) await expand.waitFor({ timeout: 10_000 }) @@ -113,12 +113,12 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { }, 60_000) it('settles the open panel when the job is killed, keeping the streamed tail', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-settled')) + onTestFailed(() => saveFailureShot(page, 'web-e2e-live-job-settled')) expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') // Settlement arrives on the observation stream itself, so the open panel // flips without any further interaction; the roster trigger follows. - const idle = page.getByRole('button', { name: '1 task' }) + const idle = page.getByRole('button', { name: '1 background job' }) await idle.waitFor({ timeout: 20_000 }) await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md b/apps/web/tests/snapshots/live-job-stream/settled.expected.md similarity index 100% rename from apps/web/tests/snapshots/live-activity-stream/settled.expected.md rename to apps/web/tests/snapshots/live-job-stream/settled.expected.md diff --git a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md similarity index 100% rename from apps/web/tests/snapshots/live-activity-stream/streaming.expected.md rename to apps/web/tests/snapshots/live-job-stream/streaming.expected.md diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 0f95b48551..38e021b1aa 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -95,7 +95,7 @@ "tests/subagent-interrupt-ui.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", "tests/background-job-list.e2e.ts", - "tests/live-activity-stream.e2e.ts", + "tests/live-job-stream.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", "tests/turn-tail-actions.e2e.ts", diff --git a/docs/architecture.md b/docs/architecture.md index 8a2f9b772c..f36c956d9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,7 +129,7 @@ New behavior attaches to a documented extension point. Changing the loop itself | Add persistent terminal execution | register a `ctx.terminals` backend plus `dsh-tool-terminal` | | Add a human command | register on `ctx.commands`; it dispatches without a model turn | | Add background work | register on `ctx.jobs`; `job_*` tools collect or stop it | -| Stream live output to observers | open a `ctx.activities` handle; the Web client renders it | +| Stream live output to observers | declare `record: true` on the `ctx.jobs` start; the Web client renders it | | Start a Session from an external webhook | register a trusted rule on `ctx.webhookRuntime` and mount a provider adapter | | Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index cc291fb018..67e2ae905c 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -133,7 +133,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` | | 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 | | 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 | -| 向观察者流式输出 | 打开一个 `ctx.activities` 句柄;Web 客户端负责渲染 | +| 向观察者流式输出 | 在 `ctx.jobs` 启动时声明 `record: true`;Web 客户端负责渲染 | | 从外部 webhook 启动 Session | 在 `ctx.webhookRuntime` 上注册可信规则,并挂载提供方适配器 | | 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 | | 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6c0867eeb8..db2b8db956 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -45,8 +45,6 @@ flowchart LR pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
        Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
        Host directory-picking Remote controller"] - pkg_api_activity_controller["api-activity-controller"] - svc_activityController["ctx.activityController
        Host Activity Remote controller"] svc_invariants["ctx.invariants
        Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -186,11 +184,6 @@ flowchart LR svc_jobs["ctx.jobs
        Background job registry"] pkg_jobs_local["jobs-local"] pkg_tool_jobs["tool-jobs"] - pkg_activity["activity"] - svc_activities["ctx.activities
        Live activity observation registry"] - pkg_activity_local["activity-local"] - pkg_tool_workflow["tool-workflow"] - pkg_activity_controller["activity-controller"] pkg_web["web"] svc_web["ctx.web
        Web access provider registry"] pkg_web_search_exa["web-search-exa"] @@ -214,6 +207,7 @@ flowchart LR pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
        Workflow script engine"] pkg_workflow_worker_thread["workflow-worker-thread"] + pkg_tool_workflow["tool-workflow"] pkg_webhook["webhook"] svc_webhookRuntime["ctx.webhookRuntime
        Webhook rule runtime"] pkg_webhook_github["webhook-github"] @@ -223,13 +217,10 @@ flowchart LR pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
        Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
        Dynamic Cordis inspect registry"] - pkg_activity --> svc_activities - pkg_activity_local --> svc_activities pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets - pkg_api_activity_controller --> svc_activityController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences @@ -342,10 +333,6 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry - svc_activities --> pkg_activity_controller - svc_activities --> pkg_tool_bash - svc_activities --> pkg_tool_pwsh - svc_activities --> pkg_tool_workflow svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_agent_spine_demo @@ -382,6 +369,7 @@ flowchart LR svc_invariants --> pkg_session svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs + svc_jobs --> pkg_tool_pwsh svc_jobs --> pkg_tool_subagent svc_jobs --> pkg_tool_terminal svc_llm --> pkg_agent_loop @@ -489,7 +477,6 @@ flowchart LR | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | -| `ctx.activityController` | `core` | [`api-activity-controller`](../packages/api/activity-controller) | - | - | - | Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | @@ -535,8 +522,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | -| `ctx.activities` | `seam` | [`activity`](../packages/activity/activity) | [`activity-local`](../packages/activity/activity-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-workflow`](../packages/workflow/tool-workflow), `activity-controller` | - | Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched. | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 424eff1c5e..64b72e0ff7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -33,22 +33,6 @@ Depends on: `Stream` (`@agentclientprotocol/sdk`) Source: [`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts) -
        - -## `@deepseek-ai/dsh-activity-local` - -```ts config-catalog -/** Configuration for the process-local observation registry. */ -export interface Config { - /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ - retainBytes?: number - /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ - settledRetainBytes?: number -} -``` - -Source: [`packages/activity/activity-local/src/index.ts:31`](../packages/activity/activity-local/src/index.ts) - ## `@deepseek-ai/dsh-agent-default-model` @@ -292,24 +276,6 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md) Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) - - -## `@deepseek-ai/dsh-api-activity-controller` - -Requires: `typert` - -```ts config-catalog -/** Configuration for the Activity Remote owner. */ -export interface Config { - /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ - flushMs?: number - /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ - maxFrameBytes?: number -} -``` - -Source: [`packages/api/activity-controller/src/index.ts:26`](../packages/api/activity-controller/src/index.ts) - ## `@deepseek-ai/dsh-api-gateway` @@ -339,10 +305,14 @@ export interface Config { readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean + /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number } ``` -Source: [`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) @@ -988,10 +958,14 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number + /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number } ``` -Source: [`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) +Source: [`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts) @@ -2712,12 +2686,12 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ - activityPollMs?: number + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } ``` -Source: [`packages/shell/tool-bash/src/index.ts:35`](../packages/shell/tool-bash/src/index.ts) +Source: [`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) @@ -2879,12 +2853,12 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ - activityPollMs?: number + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } ``` -Source: [`packages/shell/tool-pwsh/src/index.ts:60`](../packages/shell/tool-pwsh/src/index.ts) +Source: [`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts) @@ -3158,7 +3132,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:33`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts) @@ -3454,7 +3428,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-activity` ([`packages/client/ui-activity/src/index.ts`](../packages/client/ui-activity/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval` ([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) @@ -3468,6 +3441,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-input-trigger` ([`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-jobs` ([`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-message-feedback` ([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model-selection` ([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) @@ -3528,7 +3502,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). -- `@deepseek-ai/dsh-activity` — abstract `ActivityRegistry` ([`packages/activity/activity/src/index.ts`](../packages/activity/activity/src/index.ts)) - `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 48aacf9dc6..fc05e2ec6b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -69,8 +69,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## Non-harness or undeclared event strings seen in package source diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 218ec66b69..8727d17738 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: f1a2f29c9e61567a92fb8b98737446c2258087a9 -module-graph.zh.md: 86845aa00035ab18ead49e6f90a2e66c18984028 +module-graph.md: 6b69abd956658b5bf17f0b49158ec8f366888d94 +module-graph.zh.md: 4af048cc46db9bcf990a702d29970a67ff222949 diff --git a/docs/module-graph.md b/docs/module-graph.md index f1a2f29c9e..6b69abd956 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -107,12 +107,7 @@ flowchart TD subgraph group_acp["packages/acp"] pkg_acp["acp"] end - subgraph group_activity["packages/activity"] - pkg_activity["activity"] - pkg_activity_local["activity-local"] - end subgraph group_api["packages/api"] - pkg_api_activity_controller["api-activity-controller"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] @@ -141,7 +136,6 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_store["client-store"] - pkg_client_ui_activity["client-ui-activity"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] @@ -154,6 +148,7 @@ flowchart TD pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] + pkg_client_ui_jobs["client-ui-jobs"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_message_feedback["client-ui-message-feedback"] pkg_client_ui_model_selection["client-ui-model-selection"] @@ -727,12 +722,6 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_activity --> pkg_agent - pkg_activity --> pkg_brand - pkg_activity --> pkg_invariants - pkg_activity --> pkg_jobs - pkg_activity --> pkg_llm - pkg_activity --> pkg_session pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model pkg_headless --> pkg_invariants @@ -873,20 +862,6 @@ flowchart TD pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools - pkg_activity_local --> pkg_activity - pkg_activity_local --> pkg_agent - pkg_activity_local --> pkg_invariants - pkg_activity_local --> pkg_scope - pkg_api_activity_controller --> pkg_activity - pkg_api_activity_controller --> pkg_agent - pkg_api_activity_controller --> pkg_api_gateway - pkg_api_activity_controller --> pkg_client_connection - pkg_api_activity_controller --> pkg_client_store - pkg_api_activity_controller --> pkg_invariants - pkg_api_activity_controller --> pkg_jobs - pkg_api_activity_controller --> pkg_llm - pkg_api_activity_controller --> pkg_session - pkg_api_activity_controller --> pkg_typert_protocol pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction pkg_command_compact --> pkg_invariants @@ -1017,7 +992,6 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session - pkg_tool_workflow --> pkg_activity pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm @@ -1075,7 +1049,6 @@ flowchart TD pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol - pkg_tool_bash --> pkg_activity pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1087,7 +1060,6 @@ flowchart TD pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval - pkg_tool_pwsh --> pkg_activity pkg_tool_pwsh --> pkg_agent pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_jobs @@ -1366,7 +1338,6 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | -| [`client-ui-activity`](../packages/client/ui-activity) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — | @@ -1379,6 +1350,7 @@ flowchart TD | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | +| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | — | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — | @@ -1525,7 +1497,6 @@ flowchart TD | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`activity`](../packages/activity/activity) | `activity` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1548,8 +1519,6 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | -| [`activity-local`](../packages/activity/activity-local) | `activity` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope) | -| [`api-activity-controller`](../packages/api/activity-controller) | `api` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1572,7 +1541,7 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | @@ -1581,8 +1550,8 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 86845aa000..4af048cc46 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -109,12 +109,7 @@ flowchart TD subgraph group_acp["packages/acp"] pkg_acp["acp"] end - subgraph group_activity["packages/activity"] - pkg_activity["activity"] - pkg_activity_local["activity-local"] - end subgraph group_api["packages/api"] - pkg_api_activity_controller["api-activity-controller"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] @@ -143,7 +138,6 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_store["client-store"] - pkg_client_ui_activity["client-ui-activity"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] @@ -156,6 +150,7 @@ flowchart TD pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] + pkg_client_ui_jobs["client-ui-jobs"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_message_feedback["client-ui-message-feedback"] pkg_client_ui_model_selection["client-ui-model-selection"] @@ -729,12 +724,6 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_activity --> pkg_agent - pkg_activity --> pkg_brand - pkg_activity --> pkg_invariants - pkg_activity --> pkg_jobs - pkg_activity --> pkg_llm - pkg_activity --> pkg_session pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model pkg_headless --> pkg_invariants @@ -875,20 +864,6 @@ flowchart TD pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools - pkg_activity_local --> pkg_activity - pkg_activity_local --> pkg_agent - pkg_activity_local --> pkg_invariants - pkg_activity_local --> pkg_scope - pkg_api_activity_controller --> pkg_activity - pkg_api_activity_controller --> pkg_agent - pkg_api_activity_controller --> pkg_api_gateway - pkg_api_activity_controller --> pkg_client_connection - pkg_api_activity_controller --> pkg_client_store - pkg_api_activity_controller --> pkg_invariants - pkg_api_activity_controller --> pkg_jobs - pkg_api_activity_controller --> pkg_llm - pkg_api_activity_controller --> pkg_session - pkg_api_activity_controller --> pkg_typert_protocol pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction pkg_command_compact --> pkg_invariants @@ -1019,7 +994,6 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session - pkg_tool_workflow --> pkg_activity pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm @@ -1077,7 +1051,6 @@ flowchart TD pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol - pkg_tool_bash --> pkg_activity pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1089,7 +1062,6 @@ flowchart TD pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval - pkg_tool_pwsh --> pkg_activity pkg_tool_pwsh --> pkg_agent pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_jobs @@ -1368,7 +1340,6 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | -| [`client-ui-activity`](../packages/client/ui-activity) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — | @@ -1381,6 +1352,7 @@ flowchart TD | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | +| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | — | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — | @@ -1527,7 +1499,6 @@ flowchart TD | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`activity`](../packages/activity/activity) | `activity` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1550,8 +1521,6 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | -| [`activity-local`](../packages/activity/activity-local) | `activity` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope) | -| [`api-activity-controller`](../packages/api/activity-controller) | `api` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1574,7 +1543,7 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | @@ -1583,8 +1552,8 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index d75bd368d5..f3340ebeb4 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -44,7 +44,6 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | | [jobs.md](jobs.md) | the background-job runtime: branded `JobId`s, the producer contract, consumer views, and `ctx.jobs` service behavior | -| [activity.md](activity.md) | live activity observation: branded `ActivityId`s, the producer handle, non-consuming offset reads, and the wire streams | | [permission-presets.md](permission-presets.md) | the permission-preset layer: `PresetSpec`/`PresetOption`, the derived `custom` state, the log-only `permission/preset` event | | [plan.md](plan.md) | plan mode: the log-only `plan/mode` state, pending-selection flush, `PlanModeConfig`, the `exit_plan_mode` review arc | | [invariants.md](invariants.md) | the runtime-invariant registry: selection `Config`, `InvariantInstaller`/`InvariantFailure`, the empty-companion contract | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 589a72d81f..2e4523f03d 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -44,7 +44,6 @@ | [spill.md](spill.zh.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | | [workflow.md](workflow.zh.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | | [jobs.md](jobs.zh.md) | 后台任务运行时:品牌化 `JobId`、producer 约定、消费方视图和 `ctx.jobs` 服务行为 | -| [activity.md](activity.zh.md) | 实时活动观察:品牌化 `ActivityId`、生产者句柄、非消费 offset 读取与 wire 流 | | [permission-presets.md](permission-presets.zh.md) | 权限预设层:`PresetSpec`/`PresetOption`、派生的 `custom` 状态、仅记日志的 `permission/preset` 事件 | | [plan.md](plan.zh.md) | 计划模式:仅记日志的 `plan/mode` 状态、待定选择的冲刷、`PlanModeConfig`、`exit_plan_mode` 审阅流程 | | [invariants.md](invariants.zh.md) | 运行时不变式注册表:选择配置 `Config`、`InvariantInstaller`/`InvariantFailure`、空配套插件约定 | diff --git a/docs/subsystems/activity.md b/docs/subsystems/activity.md deleted file mode 100644 index 000bdc30be..0000000000 --- a/docs/subsystems/activity.md +++ /dev/null @@ -1,141 +0,0 @@ -# Live Activity Observation - -English | [中文](activity.zh.md) - -Types shared by streaming-output producers, `ctx.activities`, and the observation consumers. The [observation-seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) owns the design; this page records the cross-package vocabulary from [`packages/activity/activity/src/types.ts`](../../packages/activity/activity/src/types.ts) and the wire frames from [`packages/api/activity-controller/src/types.ts`](../../packages/api/activity-controller/src/types.ts). - -An **activity** is one observable unit of long-running work: an append-only bounded output stream plus live status. The registry is a pure observation plane — it starts nothing, cancels nothing, and is invisible to the model. Producers keep their execution resources and their model-facing surfaces (`ctx.jobs` consuming cursors, tool results); observers read non-consumingly at absolute byte offsets, so any number of them coexist with the model's single consuming cursor. - -## Ids, kinds, and correlation - -`ActivityId` is a [branded id](core.md#branded-ids) generated as `-N`; visibility relies on owner authorization, not id secrecy. `ActivityKindMap` is merge-extensible (`bash` seeded; `pwsh` and `workflow` merged by their producers). `ActivityCorrelation` links one activity to the `CallId` that started the work and the `JobId` carrying its model-facing control surface, so a consumer attaches the stream to an existing card or row without guessing from labels. - -## Producer face - -`open(spec)` returns an `ActivityHandle` with synchronous `append(text, { channel?, gapBefore? })`, `updateDetail(detail)`, and first-wins `end(outcome)`. Offsets advance by each chunk's UTF-8 byte length; `gapBefore` marks producer-side loss so a discontinuity stays visible. After `end` — including a teardown force-end — `append` and `updateDetail` log and drop rather than throw, so a producer's trailing flush cannot break its own teardown. - -`pumpActivityOutput(handle, sources, { pollMs, done })` adapts pull substrates: it copies each non-consuming offset reader (the subprocess `readFrom(fromByte)` family, re-exposed on `ShellProcess.observed`) into the handle at a bounded cadence and drains once more after `done` settles. Push-natural producers (the workflow tool's run mirror) append directly. - -## Observation - -`read(id, from, caller?)` returns the retained chunks overlapping `[from, total)` and the resume offset `next`, consuming nothing. Offsets stay absolute across eviction: `outputEarliest` on the snapshot names the oldest retained byte, and a read below it is `lossy: true`, never an error. Retention is provider policy ([`dsh-activity-local`](../../packages/activity/activity-local/README.md): `retainBytes` live, trimmed to `settledRetainBytes` at settlement). - -Two effect-scoped listener families deliver owner-relative on the jobs-registry terms: `onActivitiesChanged` fires per visible-set change (open, detail, settlement, removal) so consumers re-read whole rows, and `onOutput` signals stream advancement (append or settlement) carrying only the id, so each consumer reads from its own cursor. - -## Wire transport - -[`dsh-api-activity-controller`](../../packages/api/activity-controller/README.md) exposes the plane to browsers as two Remote streams: `activity.control` (baseline plus whole-bucket roster replacement frames) and `activity.observe` (one `opened` anchor, coalesced `output` frames, then the terminal `status` on the same stream, after which the generation closes). Output frames are transient — durable history stays with the producers' tool results, exactly as the [web job display decision](../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md) requires. [`dsh-client-ui-activity`](../../packages/client/ui-activity/README.md) renders the roster in the session header and opens observation streams only while a panel is expanded. - - - - - -## Cordis API - -Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - - -### `ctx.activities` — `ActivityRegistry` (abstract seam) - -Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). - -Implementations must honor these semantics: - -- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. -- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource. -- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap. -- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition's own carrier — sees every owner, while one registered under an agent composition's scope sees exactly the agents composed under it. Every listener is contained. - -```ts cordis-catalog -/** - * Validate the spec, attach owner cleanup, and atomically register one - * activity. A rejection leaves no id; after return the producer owns the - * handle and the visible set has changed. - * @param spec - activity identity, owner, and correlation. - * @returns the producer face of the registered activity. - */ -abstract open(spec: ActivityOpen): ActivityHandle - -/** - * List caller-owned and unowned activities in registration order without - * exposing another session's labels or output. - * @param caller - reading agent; a non-agent caller sees only unowned activities. - * @returns fresh snapshots. - */ -abstract list(caller?: Agent): ActivitySnapshot[] - -/** - * Return a fresh snapshot. Throws for an unknown or foreign activity. - * @param id - activity to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ -abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot - -/** - * Read retained output from an absolute byte offset without consuming it. - * Resume by passing a previous read's `next`; a foreign offset inside a - * retained chunk returns that whole chunk (its `at` may precede `from`). - * Throws for an unknown or foreign activity or a negative or non-integer - * offset. - * @param id - activity to read. - * @param from - absolute byte offset to read from (0 for the retained head). - * @param caller - reading agent checked against the owner. - * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. - */ -abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead - -/** - * Register an effect-scoped observer of visible-set changes: opening, - * detail updates, settlement, owner-disposal removal, and the emptying that - * service disposal commits — so an observer re-reads rather than - * accumulating deltas. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned activity changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ -abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void - -/** - * Register an effect-scoped observer of stream advancement — one signal per - * committed append and one at settlement, carrying only the activity id. - * A consumer schedules a {@link read} from its own cursor; the registry - * never pushes payloads. Listeners are contained and never awaited. - * @param listener - receives the id whose stream advanced. - * @returns disposer that unregisters the listener. - */ -abstract onOutput(listener: ActivityOutputListener): () => void -``` - -Types: [Agent](core.md) - -Source: [`packages/activity/activity/src/index.ts`](../../packages/activity/activity/src/index.ts) - - - -### `ctx.activityController` — `ActivityController` - -Host service backing the generated `ctx.remote.activity` namespace. - -```ts cordis-catalog -/** - * Stream a complete roster baseline followed by whole-bucket replacements. - * @param signal - generation cancellation. - * @returns baseline followed by per-owner roster replacement frames. - */ -@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable - -/** - * Stream one activity's retained output from an absolute byte offset, then - * its terminal status once settled and drained. Non-consuming: the - * model-facing cursors never observe these reads. - * @param request - target activity and optional resume offset. - * @param signal - generation cancellation. - * @returns anchor, coalesced output frames, and the terminal status. - */ -@Remote({ mode: 'stream' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable -``` - -Source: [`packages/api/activity-controller/src/index.ts`](../../packages/api/activity-controller/src/index.ts) - diff --git a/docs/subsystems/activity.zh.md b/docs/subsystems/activity.zh.md deleted file mode 100644 index 1fc79e9d59..0000000000 --- a/docs/subsystems/activity.zh.md +++ /dev/null @@ -1,141 +0,0 @@ -# 实时活动观察 - -[English](activity.md) | 中文 - -流式输出生产者、`ctx.activities` 与观察消费者共享的类型。设计归[观察 seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)所有;本页记录 [`packages/activity/activity/src/types.ts`](../../packages/activity/activity/src/types.ts) 的跨包词汇与 [`packages/api/activity-controller/src/types.ts`](../../packages/api/activity-controller/src/types.ts) 的 wire 帧。 - -**activity** 是一个可观察的长时工作单元:一条 append-only 的有界输出流加实时状态。注册表是纯观察面——不启动、不取消任何工作,对模型不可见。生产者保留自己的执行资源与模型面(`ctx.jobs` 消费游标、工具结果);观察者按绝对字节 offset 非消费读取,任意数量的观察者可与模型的单一消费游标共存。 - -## Id、kind 与关联 - -`ActivityId` 是[品牌化 id](core.zh.md#branded-ids),生成为 `-N`;可见性靠 owner 授权而非 id 保密。`ActivityKindMap` 可合并扩展(内置 `bash`;`pwsh` 与 `workflow` 由各自生产者合并)。`ActivityCorrelation` 把一个 activity 关联到发起工作的 `CallId` 与承载其模型控制面的 `JobId`,消费者据此把流挂到已有卡片或行上,不必靠 label 猜。 - -## 生产者面 - -`open(spec)` 返回 `ActivityHandle`:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)` 与 first-wins 的 `end(outcome)`。offset 按每个 chunk 的 UTF-8 字节长度推进;`gapBefore` 标记生产者侧丢失,让断点保持可见。`end` 之后——包括 teardown 强制 end——`append` 与 `updateDetail` 记日志后丢弃而不抛错,生产者的收尾 flush 不会砸掉自己的 teardown。 - -`pumpActivityOutput(handle, sources, { pollMs, done })` 适配 pull 型底座:把每个非消费 offset 读取器(subprocess 的 `readFrom(fromByte)` 家族,经 `ShellProcess.observed` 复出)按有界节奏复制进句柄,并在 `done` 结算后再排干一次。push 天然的生产者(workflow 工具的运行镜像)直接 append。 - -## 观察 - -`read(id, from, caller?)` 返回与 `[from, total)` 重叠的保留 chunk 及续读 offset `next`,绝不消费。offset 跨淘汰稳定:快照上的 `outputEarliest` 指明最老保留字节,低于它的读取得到 `lossy: true` 而非错误。保留量是 provider 策略([`dsh-activity-local`](../../packages/activity/activity-local/README.zh.md):存活 `retainBytes`,结算时裁到 `settledRetainBytes`)。 - -两个 effect 范围的监听族按 jobs 注册表条款做 owner 相对投递:`onActivitiesChanged` 在每次可见集变化(开启、detail、结算、移除)时触发,消费者整读行;`onOutput` 发出流推进信号(append 或结算),只带 id,各消费者按自己的游标读取。 - -## Wire 传输 - -[`dsh-api-activity-controller`](../../packages/api/activity-controller/README.zh.md) 以两条 Remote 流把观察面暴露给浏览器:`activity.control`(baseline + 整桶 roster 替换帧)与 `activity.observe`(一帧 `opened` 锚点、合并的 `output` 帧、同流的终态 `status`,之后本代关闭)。输出帧是瞬态的——持久历史仍在生产者的工具结果里,与 [web job display 决策](../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md)的要求一致。[`dsh-client-ui-activity`](../../packages/client/ui-activity/README.zh.md) 在会话头部渲染 roster,只在面板展开时打开观察流。 - - - - - -## Cordis API - -Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - - -### `ctx.activities` — `ActivityRegistry` (abstract seam) - -Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). - -Implementations must honor these semantics: - -- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. -- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource. -- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap. -- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition's own carrier — sees every owner, while one registered under an agent composition's scope sees exactly the agents composed under it. Every listener is contained. - -```ts cordis-catalog -/** - * Validate the spec, attach owner cleanup, and atomically register one - * activity. A rejection leaves no id; after return the producer owns the - * handle and the visible set has changed. - * @param spec - activity identity, owner, and correlation. - * @returns the producer face of the registered activity. - */ -abstract open(spec: ActivityOpen): ActivityHandle - -/** - * List caller-owned and unowned activities in registration order without - * exposing another session's labels or output. - * @param caller - reading agent; a non-agent caller sees only unowned activities. - * @returns fresh snapshots. - */ -abstract list(caller?: Agent): ActivitySnapshot[] - -/** - * Return a fresh snapshot. Throws for an unknown or foreign activity. - * @param id - activity to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ -abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot - -/** - * Read retained output from an absolute byte offset without consuming it. - * Resume by passing a previous read's `next`; a foreign offset inside a - * retained chunk returns that whole chunk (its `at` may precede `from`). - * Throws for an unknown or foreign activity or a negative or non-integer - * offset. - * @param id - activity to read. - * @param from - absolute byte offset to read from (0 for the retained head). - * @param caller - reading agent checked against the owner. - * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. - */ -abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead - -/** - * Register an effect-scoped observer of visible-set changes: opening, - * detail updates, settlement, owner-disposal removal, and the emptying that - * service disposal commits — so an observer re-reads rather than - * accumulating deltas. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned activity changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ -abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void - -/** - * Register an effect-scoped observer of stream advancement — one signal per - * committed append and one at settlement, carrying only the activity id. - * A consumer schedules a {@link read} from its own cursor; the registry - * never pushes payloads. Listeners are contained and never awaited. - * @param listener - receives the id whose stream advanced. - * @returns disposer that unregisters the listener. - */ -abstract onOutput(listener: ActivityOutputListener): () => void -``` - -Types: [Agent](core.zh.md) - -Source: [`packages/activity/activity/src/index.ts`](../../packages/activity/activity/src/index.ts) - - - -### `ctx.activityController` — `ActivityController` - -Host service backing the generated `ctx.remote.activity` namespace. - -```ts cordis-catalog -/** - * Stream a complete roster baseline followed by whole-bucket replacements. - * @param signal - generation cancellation. - * @returns baseline followed by per-owner roster replacement frames. - */ -@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable - -/** - * Stream one activity's retained output from an absolute byte offset, then - * its terminal status once settled and drained. Non-consuming: the - * model-facing cursors never observe these reads. - * @param request - target activity and optional resume offset. - * @param signal - generation cancellation. - * @returns anchor, coalesced output frames, and the terminal status. - */ -@Remote({ mode: 'stream' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable -``` - -Source: [`packages/api/activity-controller/src/index.ts`](../../packages/api/activity-controller/src/index.ts) - diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 092da7b6f7..1b8e58f27b 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -23,7 +23,7 @@ interface JobKindMap { ## Producer contract -`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. +`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` with the job's producer face and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. ```ts type-equiv /** @@ -38,7 +38,9 @@ interface JobStart { label: string /** * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. + * output read, including controller status metadata. Independent of record + * retention: it bounds the consuming model surface, never + * {@link JobRegistry.readRecord}. */ outputLimitBytes?: number /** @@ -49,11 +51,22 @@ interface JobStart { */ owner?: Agent /** - * Start the work after preflight and synchronously return its hooks. Called - * once; a throw leaves nothing registered, and the producer must clean up any - * partially started resources. + * Declares an observable output record beside the model-facing surfaces: + * {@link RunningJob.append} retains chunks in a bounded ring that any number + * of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + * Without the declaration `append` logs and drops. */ - run(): JobHooks + record?: true + /** + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the record append and live-detail writers. + */ + run(job: RunningJob): JobHooks } ``` @@ -71,13 +84,16 @@ interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. + * registry record without claiming that the work stopped. Settlement also + * ends the record: fold any record-filling pump into this promise so the + * final drain lands before the registry trims and closes the stream. */ done: Promise /** * Consume output produced since the previous call. The producer formats * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. + * job has one consuming cursor. Independent of the record: this is the + * model-facing projection, {@link JobRegistry.readRecord} the observer one. */ readOutput?(): string } @@ -95,6 +111,70 @@ interface JobOutcome { } ``` +## Observation record + +A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. + +```ts type-equiv +/** + * Producer face of one registered job, handed to {@link JobStart.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — both methods log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. + */ +interface RunningJob { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** + * Append one record chunk. Offsets advance by the chunk's UTF-8 byte + * length; an empty chunk is dropped without waking observers. Without a + * {@link JobStart.record} declaration the chunk is logged and dropped. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: JobAppendOptions): void + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * Works for every job, with or without a record. + * @param detail - the new detail line. + */ + updateDetail(detail: string): void +} +``` + +```ts type-equiv +/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ +interface JobRecordChunk { + /** Absolute offset of the chunk's first byte. */ + at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + text: string + /** Stream label, when the producer supplied one. */ + channel?: JobChannel + /** Producer-reported loss immediately before this chunk. */ + gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link JobRegistry.readRecord}. */ +interface JobRecordRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly JobRecordChunk[] + /** + * Offset to resume from — the record's current `outputTotal`. Always a + * chunk boundary: appends land whole and trimming only advances chunk + * starts, and consumers concatenate `chunks` under that assumption, so a + * provider serving partial chunks would silently duplicate text. + */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean +} +``` + ## Consumer views Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another reporter has delivered or committed to deliver the terminal state, including the teardown cancel that drains an owner or the service. @@ -121,7 +201,10 @@ interface JobSnapshot { ownerSession?: SessionId /** Current lifecycle state. */ status: JobStatus - /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ + /** + * Kind-specific status detail: live progress while the producer updates it + * through {@link RunningJob.updateDetail}, the terminal detail once settled. + */ detail?: string /** Epoch ms when the job was registered. */ startedAt: number @@ -135,6 +218,17 @@ interface JobSnapshot { * otherwise spend a model request per teardown layer. */ reported: boolean + /** + * Total UTF-8 bytes ever appended to the record — the offset the next chunk + * starts at. Present exactly when the job declared {@link JobStart.record}. + */ + outputTotal?: number + /** + * Offset of the oldest retained record byte. Greater than zero exactly when + * retention dropped the head; a reader starting below it gets a lossy read. + * Present exactly when the job declared {@link JobStart.record}. + */ + outputEarliest?: number } ``` @@ -154,7 +248,7 @@ interface JobRead { ## Service behavior -The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onJobDone` and `onJobsChanged` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. +The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, non-consuming `readRecord`, `kill`, bounded `wait`, failure-isolated `onJobDone`, `onJobsChanged`, and `onOutput` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity, and `retainBytes` (default 262144) with `settledRetainBytes` (default 16384) bound each declared record's live and settled retention. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. @@ -176,6 +270,8 @@ Implementations must honor these semantics: - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. - Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. - start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected. +- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle. ```ts cordis-catalog /** @@ -274,6 +370,31 @@ abstract onJobDone(listener: JobDoneListener): () => void */ abstract onJobsChanged(listener: JobsChangedListener): () => void +/** + * Read retained record output from an absolute byte offset without consuming + * it. Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Never marks the job reported. Throws for an unknown or foreign job, a job + * without a {@link JobStart.record} declaration, or a negative or + * non-integer offset. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead + +/** + * Register an effect-scoped observer of record advancement — one signal per + * committed append and one at settlement, carrying only the job id. A + * consumer schedules a {@link readRecord} from its own cursor; the registry + * never pushes payloads. Delivery is owner-relative on the same terms as + * {@link onJobsChanged}. Listeners are contained and never awaited. + * @param listener - receives the id whose record advanced. + * @returns disposer that unregisters the listener. + */ +abstract onOutput(listener: JobOutputListener): () => void + /** * Attach an effect-scoped controller that can read and stop jobs. It serves the * owners its registering context's scope covers, and {@link start} refuses an diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 9dc390eb30..5928c640c4 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -176,6 +176,8 @@ Implementations must honor these semantics: - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. - Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. - start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected. +- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle. ```ts cordis-catalog /** @@ -274,6 +276,31 @@ abstract onJobDone(listener: JobDoneListener): () => void */ abstract onJobsChanged(listener: JobsChangedListener): () => void +/** + * Read retained record output from an absolute byte offset without consuming + * it. Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Never marks the job reported. Throws for an unknown or foreign job, a job + * without a {@link JobStart.record} declaration, or a negative or + * non-integer offset. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead + +/** + * Register an effect-scoped observer of record advancement — one signal per + * committed append and one at settlement, carrying only the job id. A + * consumer schedules a {@link readRecord} from its own cursor; the registry + * never pushes payloads. Delivery is owner-relative on the same terms as + * {@link onJobsChanged}. Listeners are contained and never awaited. + * @param listener - receives the id whose record advanced. + * @returns disposer that unregisters the listener. + */ +abstract onOutput(listener: JobOutputListener): () => void + /** * Attach an effect-scoped controller that can read and stop jobs. It serves the * owners its registering context's scope covers, and {@link start} refuses an diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 8ad0ddf475..3ec8288309 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: d35395fb80a6558e15b82cdb76559f59e492f425 -session.zh.md: d3bc750b5c198e38efa94c88f95dcb9406c9059b +session.md: da62600efed8b0cd5bb134fd664582634eb0d2c3 +session.zh.md: 448d173d694d17afc9e03cd689ae07a1369b155c diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index d35395fb80..da62600efe 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -740,6 +740,18 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * @returns one complete baseline followed by live replacement frames. */ @Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable ``` Types: [SessionHeader](persistence.md) · [SessionId](core.md) · [SessionSearchRequest](session-query.md) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d3bc750b5c..448d173d69 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -744,6 +744,18 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * @returns one complete baseline followed by live replacement frames. */ @Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable ``` Types: [SessionHeader](persistence.zh.md) · [SessionId](core.zh.md) · [SessionSearchRequest](session-query.zh.md) diff --git a/packages/activity/README.i18n.yaml b/packages/activity/README.i18n.yaml deleted file mode 100644 index 3030bc2d1d..0000000000 --- a/packages/activity/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/activity/README.md -README.md: bc9e67703b2888048eacd3a8e060aaa739948eac -README.zh.md: 26f339b8f262b90001355b690f736e2d1089cab5 diff --git a/packages/activity/README.md b/packages/activity/README.md deleted file mode 100644 index bc9e67703b..0000000000 --- a/packages/activity/README.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -description: "The activity package group: the non-consuming live-output observation plane under packages/activity/, for readers choosing or navigating the family." -kind: "package-group" ---- - -# activity/ — streaming-output observation family - -English | [中文](README.zh.md) - -## Summary - -This family lets any number of independent observers watch a long-running producer's live output without touching the model-facing paths: an activity is an append-only bounded output stream plus live status, read at absolute byte offsets that survive eviction. `activity` defines the registry contract and vocabulary; `activity-local` implements the in-memory ring-buffer registry. The plane is optional and invisible to the model — a composition without it loses only live observation. - -## Table of Contents - -- [Packages](#packages) -- [Related documentation](#related-documentation) -- [Dev Note](#dev-note) - ------ - - -## Packages - -The Service Definition and its local implementation: - -| Package | Role | ctx key | -|---|---|---| -| [`activity/`](activity/README.md) | Defines the observation registry and vocabulary | `ctx.activities` | -| [`activity-local/`](activity-local/README.md) | Implements the in-memory ring-buffer registry | registers on `ctx.activities` | - - -## Related documentation - -- [`dsh-api-activity-controller`](../api/activity-controller/README.md) — the Web transport and client model over this plane. -- [`dsh-client-ui-activity`](../client/ui-activity/README.md) — the session-header task list rendering it. -- [docs/subsystems/activity.md](../../docs/subsystems/activity.md) — the subsystem reference: offsets, retention, listener delivery. -- [Activity observation seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) — the design decision; background bash/pwsh and the workflow tool are the shipped producers. - - -## Dev Note - -None. diff --git a/packages/activity/README.zh.md b/packages/activity/README.zh.md deleted file mode 100644 index 26f339b8f2..0000000000 --- a/packages/activity/README.zh.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -description: "activity 包组:packages/activity/ 下的非消费实时输出观察面,供选择或导航该家族的读者使用。" -kind: "package-group" ---- - -# activity/ — 流式输出观察家族 - -[English](README.md) | 中文 - -## 概述 - -这个家族让任意数量的独立观察者在不触碰模型可见路径的前提下观看长时运行生产者的实时输出:一个 activity 是一条只追加、有界的输出流加实时状态,以跨淘汰仍有效的绝对字节偏移读取。`activity` 定义注册表契约与词汇;`activity-local` 实现内存环形缓冲注册表。该观察面是可选的且对模型不可见——没有它的组合只失去实时观察。 - -## 目录 - -- [包](#packages) -- [相关文档](#related-documentation) -- [开发备注](#dev-note) - ------ - - -## 包 - -Service Definition 与其本地实现: - -| 包 | 角色 | ctx 键 | -|---|---|---| -| [`activity/`](activity/README.zh.md) | 定义观察注册表与词汇 | `ctx.activities` | -| [`activity-local/`](activity-local/README.zh.md) | 实现内存环形缓冲注册表 | 注册在 `ctx.activities` | - - -## 相关文档 - -- [`dsh-api-activity-controller`](../api/activity-controller/README.zh.md)——架在该面之上的 Web 传输与客户端模型。 -- [`dsh-client-ui-activity`](../client/ui-activity/README.zh.md)——渲染它的会话头部任务列表。 -- [docs/subsystems/activity.md](../../docs/subsystems/activity.zh.md)——子系统参考:偏移、留存、监听器投递。 -- [activity 观察面 Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)——设计决策;后台 bash/pwsh 与 workflow 工具是已交付的生产者。 - - -## 开发备注 - -无。 diff --git a/packages/activity/activity-local/README.i18n.yaml b/packages/activity/activity-local/README.i18n.yaml deleted file mode 100644 index 9487171fa8..0000000000 --- a/packages/activity/activity-local/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/activity/activity-local/README.md -README.md: ef010e1fe859d2ffd56d736b24e490d73bbb49a5 -README.zh.md: 7d9779fc64e7e7d6e47e3a2e78fb80ca097abb6c diff --git a/packages/activity/activity-local/README.md b/packages/activity/activity-local/README.md deleted file mode 100644 index ef010e1fe8..0000000000 --- a/packages/activity/activity-local/README.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -description: "The in-memory activity registry: configurable ring-buffer retention (retainBytes, settledRetainBytes), absolute offsets across eviction, owner-scoped cleanup, and layered listener delivery for live-output observation." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-activity-local - -English | [中文](README.zh.md) - -## Summary - -`dsh-activity-local` makes `ctx.activities` real: mount its one row and every producer's live output is retained in a bounded in-process ring readers address by absolute byte offsets, with fresh snapshots and chunk copies handed out — never live state. Retention is configurable per deployment, records outlive their producer fibers, and owner disposal cleans up exactly the disposed session's rows. Choose it for the single-Host web deployment this repository ships; durable or cross-process observation needs a different provider. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Mount one `cordis.yml` row (`name: '@deepseek-ai/dsh-activity-local'`) beside `dsh-agent`; producers and consumers then reach the registry as `ctx.activities`. - -### Retention - -Each activity retains a chunk ring whose offsets stay absolute across eviction: dropping the head advances `outputEarliest`, and a single chunk larger than the live cap keeps only its UTF-8-safe tail with a `gapBefore` marker. Settlement trims the ring to the settled cap so a finished activity still shows its tail without holding the live budget. - -| Config | Default | Meaning | -|---|---|---| -| `retainBytes` | `262144` | Live output retention per activity, in UTF-8 bytes. | -| `settledRetainBytes` | `16384` | Retention kept after an activity settles. | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-activity-local) is the exhaustive source for every accepted field. - -### Lifecycle - -Records outlive producer fibers. The first activity for an owner attaches one cleanup to the exact `Agent` scope; owner disposal ends still-open records (`killed`, `owner disposed`) and removes them, announcing the removal. Service disposal ends everything, clears the store, and announces each emptied owner. Neither awaits a producer — the registry owns no execution resource. - ------ - - -## Understand the implementation - -
        -Implementation internals — click to expand - -One process-wide `LocalActivityRegistry` keeps a map of tracked records, each holding its chunk ring and cursor facts. Listeners are layered by registration scope exactly like the jobs registry, so delivery stays owner-relative in one instance, and every listener is contained. Trimming walks whole chunks and only tail-cuts a single oversized chunk on a UTF-8 boundary. - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | The registry: rings, retention, fencing, scoped listener layers, disposal | -| [`src/invariant.ts`](src/invariant.ts) | Package invariant companion | - -
        - ------ - - -## Further Exploration - -- [`dsh-activity`](../activity/README.md) — the contract this provider implements. -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.md) — offsets, retention, and delivery in one reference. -- [`dsh-api-activity-controller`](../../api/activity-controller/README.md) — the Remote streams reading this registry. - ------ - - -## Model Experience - -None, as the provider stores and serves observation state only; the producers own every model-visible surface. - -#### KV Cache effect - -None; the package never assembles or alters provider requests. - -## Known Limitations and Deferred Work - - - -These limits define when the provider is a poor fit. They are current package constraints, not a task backlog. - -- **The unowned bucket has no admission bound** — owned producers are already bounded by the jobs admission policy, but a plugin opening many unowned activities grows the store until service disposal. - - -### Dev Note - -
        -Working context for maintainers — click to expand - -None. - -
        diff --git a/packages/activity/activity-local/README.zh.md b/packages/activity/activity-local/README.zh.md deleted file mode 100644 index 7d9779fc64..0000000000 --- a/packages/activity/activity-local/README.zh.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -description: "内存 activity 注册表:可配置的环形缓冲留存(retainBytes、settledRetainBytes)、跨淘汰的绝对偏移、按 owner 作用域的清理,以及实时输出观察的分层监听器投递。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-activity-local - -[English](README.md) | 中文 - -## 概述 - -`dsh-activity-local` 让 `ctx.activities` 落地:挂载它的一行后,每个生产者的实时输出保留在有界的进程内环里,读者以绝对字节偏移寻址,拿到的是新鲜快照与 chunk 拷贝——从不是活状态。留存按部署可配置,记录比生产者 fiber 活得久,owner 释放只清理被释放会话的行。为本仓库交付的单 Host web 部署选择它;持久或跨进程观察需要别的 provider。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -在 `dsh-agent` 旁挂载一行 `cordis.yml`(`name: '@deepseek-ai/dsh-activity-local'`);生产者与消费者随即以 `ctx.activities` 触达注册表。 - -### 留存 - -每个 activity 保留一个偏移跨淘汰仍绝对的 chunk 环:丢弃头部会前移 `outputEarliest`,单个超过活跃上限的 chunk 只保留 UTF-8 安全的尾部并带 `gapBefore` 标记。结算把环裁到 settled 上限,让完成的 activity 仍展示尾部而不占活跃预算。 - -| 配置 | 默认 | 含义 | -|---|---|---| -| `retainBytes` | `262144` | 每个 activity 的活跃输出留存,UTF-8 字节。 | -| `settledRetainBytes` | `16384` | activity 结算后保留的留存量。 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-activity-local)是每个可接受字段的穷尽来源。 - -### 生命周期 - -记录比生产者 fiber 活得久。owner 的第一个 activity 在确切的 `Agent` 作用域上挂一个清理;owner 释放会终结仍开放的记录(`killed`、`owner disposed`)并移除它们、announce 该移除。服务释放终结一切、清空存储并 announce 每个被清空的 owner。两者都不等待生产者——注册表不拥有任何执行资源。 - ------ - - -## 理解实现 - -
        -实现内幕——点击展开 - -一个进程级 `LocalActivityRegistry` 维护被跟踪记录的 map,每条持有自己的 chunk 环与游标事实。监听器按注册作用域分层,与 jobs 注册表完全一致,投递在单实例内保持 owner 相对,且每个监听器都被包住。裁剪按整 chunk 走,只对单个超大 chunk 在 UTF-8 边界上做尾部截取。 - -| 文件 | 角色 | -|---|---| -| [`src/index.ts`](src/index.ts) | 注册表:环、留存、栅栏、作用域监听层、释放 | -| [`src/invariant.ts`](src/invariant.ts) | 包不变式伴生 | - -
        - ------ - - -## 进一步探索 - -- [`dsh-activity`](../activity/README.zh.md)——本 provider 实现的契约。 -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.zh.md)——偏移、留存与投递的单一参考。 -- [`dsh-api-activity-controller`](../../api/activity-controller/README.zh.md)——读取本注册表的 Remote 流。 - ------ - - -## 模型体验 - -无:provider 只存储与提供观察状态;模型可见表面全部归生产者所有。 - -#### KV Cache 影响 - -无;本包从不组装或改动 provider 请求。 - -## 已知限制与延期工作 - - - -这些限制说明该 provider 何时不合适。它们是当前包约束,不是任务积压。 - -- **无主桶没有准入上限**——有主生产者已受 jobs 准入策略约束,但打开大量无主 activity 的插件会让存储一直增长到服务释放。 - - -### 开发备注 - -
        -维护者的工作上下文——点击展开 - -无。 - -
        diff --git a/packages/activity/activity-local/package.json b/packages/activity/activity-local/package.json deleted file mode 100644 index d3b08606ce..0000000000 --- a/packages/activity/activity-local/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-activity-local", - "description": "Process-local implementation of the DeepSeek Harness streaming-output observation seam", - "version": "0.1.2-alpha.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/activity/activity-local" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - } -} diff --git a/packages/activity/activity-local/src/index.ts b/packages/activity/activity-local/src/index.ts deleted file mode 100644 index caabb5499e..0000000000 --- a/packages/activity/activity-local/src/index.ts +++ /dev/null @@ -1,438 +0,0 @@ -/** - * Process-local provider for the streaming-output observation seam - * (`ctx.activities`). It keeps every record and its bounded output ring in - * memory and hands out fresh snapshots and chunk copies, never live state. - * - * Records outlive producer fibers. Owner disposal ends still-open records and - * removes them; service disposal ends and clears everything. Neither awaits a - * producer — the registry owns no execution resource. - * @module @deepseek-ai/dsh-activity-local - */ - -import { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' -import type { ScopeLayer } from '@deepseek-ai/dsh-scope' -import { ActivityRegistry, ActivityId } from '@deepseek-ai/dsh-activity' -import type { - ActivityAppendOptions, ActivityChannel, ActivityCorrelation, ActivitiesChangedListener, - ActivityHandle, ActivityKind, ActivityOpen, ActivityOutcome, ActivityOutputChunk, - ActivityOutputListener, ActivityRead, ActivitySnapshot, ActivityStatus, -} from '@deepseek-ai/dsh-activity' - -/** Default live retention per activity, in UTF-8 bytes. */ -const DEFAULT_RETAIN_BYTES = 256 * 1024 - -/** Default retention kept after settlement, in UTF-8 bytes. */ -const DEFAULT_SETTLED_RETAIN_BYTES = 16 * 1024 - -/** Configuration for the process-local observation registry. */ -export interface Config { - /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ - retainBytes?: number - /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ - settledRetainBytes?: number -} - -/** The registry's mutable per-activity record (never handed out — see {@link LocalActivityRegistry.snapshot}). */ -interface TrackedActivity { - id: ActivityId - kind: ActivityKind - label: string - /** Exact lifecycle owner; session-id visibility is derived from it. */ - owner: Agent | undefined - correlation: ActivityCorrelation | undefined - status: ActivityStatus - detail: string | undefined - startedAt: number - finishedAt: number | undefined - /** Retained chunks in offset order; offsets stay absolute across eviction. */ - chunks: RingChunk[] - /** Sum of the retained chunks' byte lengths. */ - retainedBytes: number - /** Total UTF-8 bytes ever appended. */ - total: number - /** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */ - earliest: number -} - -/** One retained ring entry; `bytes` caches the chunk's UTF-8 length. */ -interface RingChunk { - at: number - text: string - bytes: number - channel?: ActivityChannel - gapBefore?: true -} - -/** True for the three terminal {@link ActivityStatus} values. */ -function isTerminal(status: ActivityStatus): boolean { - return status === 'completed' || status === 'failed' || status === 'killed' -} - -/** - * The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut - * advances past continuation bytes so the surviving text never starts inside - * a code point. - * @param text - the oversized chunk text. - * @param maxBytes - positive byte budget for the surviving tail. - * @returns the surviving tail and its exact byte length. - */ -function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } { - const raw = Buffer.from(text, 'utf8') - let start = raw.length - maxBytes - // The loop bound proves the index is in range; the assertion only - // discharges noUncheckedIndexedAccess. - while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1 - const tail = raw.subarray(start) - return { text: tail.toString('utf8'), bytes: tail.length } -} - -/** - * One scope's observer contributions. Both tables are anonymous because a - * contribution is identified by its own disposer, never by a name a second - * registrant could shadow. - */ -class ActivityLayer implements ScopeLayer { - readonly changed = new AnonymousEntries() - readonly output = new AnonymousEntries() - - isEmpty(): boolean { - return this.changed.isEmpty() && this.output.isEmpty() - } -} - -/** - * The in-memory `activities` registry. See the Service Definition contract in - * `@deepseek-ai/dsh-activity` for the observation, retention, and lifecycle - * semantics this implementation honors. - */ -export class LocalActivityRegistry extends ActivityRegistry { - static Config: z = z.object({ - retainBytes: z.number() - .step(1) - .min(1) - .max(Number.MAX_SAFE_INTEGER) - .default(DEFAULT_RETAIN_BYTES), - settledRetainBytes: z.number() - .step(1) - .min(1) - .max(Number.MAX_SAFE_INTEGER) - .default(DEFAULT_SETTLED_RETAIN_BYTES), - }) - - /** Schemastery-defaulted live retention cap. */ - private readonly retainBytes: number - /** Schemastery-defaulted settled retention cap. */ - private readonly settledRetainBytes: number - private store = new Map() - private counters = new Map() - /** - * Observers layered by the scope that registered them, in the jobs-registry - * shape: a contribution files into its registering context's scope, and a - * dispatch unions the global layer with the subject owner's scope chain, so - * delivery stays owner-relative in one process-wide registry. Nothing - * derives a cache from a layer, so change notification is a no-op. - */ - private readonly layers = new ScopedLayers(() => new ActivityLayer(), () => {}) - /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ - private ownerCleanups = new Map Promise | void>() - /** Service context used by contained listener reporting and teardown. */ - private readonly selfCtx: Context - - constructor(ctx: Context, config: Config) { - super(ctx) - // Schemastery validates and fills the defaults before constructing the service. - this.retainBytes = (config as Required).retainBytes - this.settledRetainBytes = (config as Required).settledRetainBytes - this.selfCtx = ctx - ctx.effect(() => () => { this.disposeAll() }, 'activities teardown') - } - - open(spec: ActivityOpen): ActivityHandle { - if (spec.kind.length === 0) throw new Error('invalid activity kind: expected a non-empty string') - if (spec.label.length === 0) throw new Error('invalid activity label: expected a non-empty string') - if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) - - const count = (this.counters.get(spec.kind) ?? 0) + 1 - this.counters.set(spec.kind, count) - const id = ActivityId(`${spec.kind}-${count}`) - const record: TrackedActivity = { - id, - kind: spec.kind, - label: spec.label, - owner: spec.owner, - correlation: spec.correlation === undefined ? undefined : { ...spec.correlation }, - status: 'running', - detail: undefined, - startedAt: Date.now(), - finishedAt: undefined, - chunks: [], - retainedBytes: 0, - total: 0, - earliest: 0, - } - this.store.set(id, record) - // Registration is complete and cannot fail from here, so the visible set - // has genuinely changed. - this.notifyChanged(record.owner) - return { - id, - append: (text, options) => { this.append(record, text, options) }, - updateDetail: (detail) => { this.updateDetail(record, detail) }, - end: (outcome) => { this.settle(record, outcome) }, - } - } - - list(caller?: Agent): ActivitySnapshot[] { - const session = caller?.id - return [...this.store.values()] - .filter(record => record.owner === undefined || record.owner.id === session) - .map(record => this.snapshot(record)) - } - - get(id: ActivityId, caller?: Agent): ActivitySnapshot { - const record = this.expect(id) - this.assertAccess(record, caller) - return this.snapshot(record) - } - - read(id: ActivityId, from: number, caller?: Agent): ActivityRead { - const record = this.expect(id) - this.assertAccess(record, caller) - if (!Number.isSafeInteger(from) || from < 0) { - throw new Error(`invalid read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`) - } - const chunks: ActivityOutputChunk[] = [] - for (const chunk of record.chunks) { - if (chunk.at + chunk.bytes <= from) continue - chunks.push({ - at: chunk.at, - text: chunk.text, - ...chunk.channel !== undefined ? { channel: chunk.channel } : {}, - ...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {}, - }) - } - return { chunks, next: record.total, lossy: from < record.earliest } - } - - onActivitiesChanged(listener: ActivitiesChangedListener): () => void { - return this.layers.effect( - this.ctx, - layer => layer.changed.append(listener), - { label: 'activities.onActivitiesChanged()' }, - ) - } - - onOutput(listener: ActivityOutputListener): () => void { - return this.layers.effect( - this.ctx, - layer => layer.output.append(listener), - { label: 'activities.onOutput()' }, - ) - } - - /** Append one chunk to a live record; log and drop on an ended one. */ - private append(record: TrackedActivity, text: string, options?: ActivityAppendOptions): void { - if (isTerminal(record.status)) { - this.selfCtx.logger.warn(`activities: append to ended activity ${record.id} dropped`) - return - } - if (text.length === 0) return - const bytes = Buffer.byteLength(text, 'utf8') - record.chunks.push({ - at: record.total, - text, - bytes, - ...options?.channel !== undefined ? { channel: options.channel } : {}, - ...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {}, - }) - record.total += bytes - record.retainedBytes += bytes - this.trim(record, this.retainBytes) - this.notifyOutput(record) - } - - /** Replace a live record's detail line; log and drop on an ended one. */ - private updateDetail(record: TrackedActivity, detail: string): void { - if (isTerminal(record.status)) { - this.selfCtx.logger.warn(`activities: detail update on ended activity ${record.id} dropped`) - return - } - record.detail = detail - this.notifyChanged(record.owner) - } - - /** - * Record the first terminal outcome, trim retention to the settled cap, and - * announce the change and the stream end. First-wins: a later call — the - * producer's own report after a teardown force-end, or a duplicate — is - * dropped silently. - */ - private settle(record: TrackedActivity, outcome: ActivityOutcome): void { - if (isTerminal(record.status)) return - record.status = outcome.status - if (outcome.detail !== undefined) record.detail = outcome.detail - record.finishedAt = Date.now() - this.trim(record, this.settledRetainBytes) - this.notifyChanged(record.owner) - this.notifyOutput(record) - } - - /** - * Drop retained head chunks until the ring fits `cap`; a single oversized - * chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. Offsets - * stay absolute: `earliest` advances over everything dropped. - */ - private trim(record: TrackedActivity, cap: number): void { - while (record.retainedBytes > cap && record.chunks.length > 1) { - const dropped = record.chunks.shift() - /* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */ - if (dropped === undefined) break - record.retainedBytes -= dropped.bytes - } - const single = record.chunks.length === 1 ? record.chunks[0] : undefined - if (single !== undefined && single.bytes > cap) { - const tail = utf8Tail(single.text, cap) - single.at += single.bytes - tail.bytes - single.text = tail.text - single.bytes = tail.bytes - single.gapBefore = true - record.retainedBytes = tail.bytes - } - record.earliest = record.chunks[0]?.at ?? record.total - } - - /** Look up an activity or fail loud. */ - private expect(id: ActivityId): TrackedActivity { - const record = this.store.get(id) - if (record === undefined) throw new Error(`unknown activity ${id}`) - return record - } - - /** - * The visibility fence: an activity with an owner is reachable only by - * callers whose session id matches (`!== undefined` semantics — an unowned - * activity is open, and a no-agent caller can never match an owned one). - */ - private assertAccess(record: TrackedActivity, caller?: Agent): void { - if (record.owner !== undefined && record.owner.id !== caller?.id) { - throw new Error(`activity ${record.id} belongs to another session`) - } - } - - /** Project a fresh read-only snapshot from the mutable record. */ - private snapshot(record: TrackedActivity): ActivitySnapshot { - const ownerSession = record.owner?.id - return { - id: record.id, - kind: record.kind, - label: record.label, - ...ownerSession !== undefined ? { ownerSession } : {}, - ...record.correlation !== undefined ? { correlation: { ...record.correlation } } : {}, - status: record.status, - ...record.detail !== undefined ? { detail: record.detail } : {}, - startedAt: record.startedAt, - ...record.finishedAt !== undefined ? { finishedAt: record.finishedAt } : {}, - outputTotal: record.total, - outputEarliest: record.earliest, - } - } - - /** - * The change observers that own `owner`'s updates: the global layer — a - * host composition's own carrier, which serves every owner — then each - * scoped layer along the owner's chain. - */ - private *changedFor(owner?: Agent): IterableIterator { - yield* this.layers.global.changed.values() - const scope = owner === undefined ? undefined : scopeOf(owner.ctx) - for (const layer of this.layers.chainLayers(scope)) yield* layer.changed.values() - } - - /** The output observers that own `owner`'s stream signals, resolved like {@link changedFor}. */ - private *outputFor(owner?: Agent): IterableIterator { - yield* this.layers.global.output.values() - const scope = owner === undefined ? undefined : scopeOf(owner.ctx) - for (const layer of this.layers.chainLayers(scope)) yield* layer.output.values() - } - - /** - * Announce that one owner's visible set changed. Each listener is contained - * so an observer cannot break a lifecycle commit that already happened. - */ - private notifyChanged(owner: Agent | undefined): void { - for (const listener of this.changedFor(owner)) { - try { - listener(owner) - } catch (error: unknown) { - this.selfCtx.logger.warn(`activities: onActivitiesChanged listener threw: ${String(error)}`) - } - } - } - - /** Announce that one activity's stream advanced (append or settlement), with per-listener containment. */ - private notifyOutput(record: TrackedActivity): void { - for (const listener of this.outputFor(record.owner)) { - try { - listener(record.id) - } catch (error: unknown) { - this.selfCtx.logger.warn(`activities: onOutput listener threw: ${String(error)}`) - } - } - } - - /** - * Attach one cleanup through the exact owner's scope. This survives - * producer reloads; the retained disposer lets service teardown detach the - * cross-fiber effect. Fails when the agent registry is absent or the owner - * is not its currently registered instance. - */ - private ensureOwnerCleanup(owner: Agent): void { - const ownerId = owner.id - const agents = this.selfCtx.get('agents') - if (agents === undefined) { - throw new Error('activity ownership requires the agent registry (load @deepseek-ai/dsh-agent)') - } - if (agents.get(ownerId) !== owner) { - throw new Error(`agent "${ownerId}" is not the registered agent instance (activity owner must be live)`) - } - if (this.ownerCleanups.has(owner)) return - // Record only after attach succeeds; a disposing scope rejects new effects. - const detach = owner.ctx.effect(() => () => { - this.ownerCleanups.delete(owner) - this.disposeOwned(owner) - }, 'activities.ownerCleanup()') - this.ownerCleanups.set(owner, detach) - } - - /** End still-open records and drop every activity owned by one exact agent lifecycle. */ - private disposeOwned(owner: Agent): void { - const owned = [...this.store.values()].filter(record => record.owner === owner) - for (const record of owned) { - if (!isTerminal(record.status)) this.settle(record, { status: 'killed', detail: 'owner disposed' }) - this.store.delete(record.id) - } - // Removal is the one visible-set change no per-activity record carries, so - // it must be announced here or an observer keeps the dropped rows forever. - if (owned.length > 0) this.notifyChanged(owner) - } - - /** End every open record, clear the store, and announce each emptied owner. */ - private disposeAll(): void { - const all = [...this.store.values()] - for (const record of all) { - if (!isTerminal(record.status)) this.settle(record, { status: 'killed', detail: 'activity registry disposed' }) - } - const emptied = new Set(all.map(record => record.owner)) - this.store.clear() - for (const owner of emptied) this.notifyChanged(owner) - // Detach cross-fiber owner effects after the shared store is quiescent. - const ownerCleanups = [...this.ownerCleanups.values()] - this.ownerCleanups.clear() - for (const cleanup of ownerCleanups) void cleanup() - } -} - -export default LocalActivityRegistry diff --git a/packages/activity/activity-local/src/invariant.ts b/packages/activity/activity-local/src/invariant.ts deleted file mode 100644 index 7e15b7db09..0000000000 --- a/packages/activity/activity-local/src/invariant.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-activity-local`. - * @module @deepseek-ai/dsh-activity-local/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-activity-local' - -/** Cordis companion plugin name. */ -export const name = 'activity-local-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: `@deepseek-ai/dsh-activity/invariant` owns per-snapshot identity, status, - * timestamp, and offset checks on every visible-set change. This provider's remaining behavior — - * ring retention against private byte caps and pull-only chunk reads — has no authoritative event - * stream to observe: a read happens on a caller's schedule, and repeating the retention arithmetic - * against private configuration would duplicate the implementation rather than check a relation. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/activity/activity-local/tests/activity.spec.ts b/packages/activity/activity-local/tests/activity.spec.ts deleted file mode 100644 index 557046d617..0000000000 --- a/packages/activity/activity-local/tests/activity.spec.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { createScope } from '@deepseek-ai/dsh-scope' -import { ActivityId } from '@deepseek-ai/dsh-activity' -import type { ActivityHandle, ActivitySnapshot } from '@deepseek-ai/dsh-activity' -import LocalActivityRegistry, { type Config as ProcessConfig } from '@deepseek-ai/dsh-activity-local' - -declare module '@deepseek-ai/dsh-activity' { - interface ActivityKindMap { - workflow: 'workflow' - } -} - -const agentScopeDisposers = new WeakMap Promise>() - -/** - * A minimal live agent whose ctx carries its own scope on a disposable plugin - * fiber, mirroring the harness convention that a live agent keys its scope. - */ -function stubAgent(ctx: Context, rawId: string): Agent { - const id = SessionId(rawId) - const scopeFiber = ctx.plugin(() => {}) - const agentCtx = createScope(scopeFiber.ctx, {}).ctx - const session = Session.create(id) - const agent = { - id, - options: {}, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle' as const, - ctx: agentCtx, - send: () => {}, - followup: () => {}, - steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), - inject: () => {}, - cancel() {}, - runMaintenance: (job: (signal: AbortSignal) => Promise) => job(new AbortController().signal), - whenIdle() { return Promise.resolve() }, - } - agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() }) - return agent -} - -async function disposeAgentScope(agent: Agent): Promise { - const dispose = agentScopeDisposers.get(agent) - if (dispose === undefined) throw new Error(`missing test scope for agent "${agent.id}"`) - await dispose() -} - -async function harness(config: ProcessConfig = {}) { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalActivityRegistry, config) - return ctx -} - -/** Concatenated retained text of one process, as an observer from `from` sees it. */ -function textFrom(ctx: Context, id: ActivityId, from = 0): string { - return ctx.activities.read(id, from).chunks.map(chunk => chunk.text).join('') -} - -describe('LocalActivityRegistry.open', () => { - it('mints per-kind ordinal ids and announces the new visible row', async () => { - const ctx = await harness() - const changes: (Agent | undefined)[] = [] - ctx.activities.onActivitiesChanged((owner) => { changes.push(owner) }) - const first = ctx.activities.open({ kind: 'bash', label: 'sleep 60' }) - const second = ctx.activities.open({ kind: 'workflow', label: 'run docs' }) - const third = ctx.activities.open({ kind: 'bash', label: 'build' }) - expect([first.id, second.id, third.id]).toEqual(['bash-1', 'workflow-1', 'bash-2']) - expect(changes).toEqual([undefined, undefined, undefined]) - expect(ctx.activities.list().map(snapshot => snapshot.id)).toEqual(['bash-1', 'workflow-1', 'bash-2']) - }) - - it('rejects an empty kind or label without minting an id', async () => { - const ctx = await harness() - expect(() => ctx.activities.open({ kind: '' as never, label: 'x' })).toThrow(/invalid activity kind/) - expect(() => ctx.activities.open({ kind: 'bash', label: '' })).toThrow(/invalid activity label/) - expect(ctx.activities.list()).toEqual([]) - }) - - it('rejects an owned open when no agent registry is loaded', async () => { - const bare = new Context() - await bare.plugin(LocalActivityRegistry) - const ghost = { id: SessionId('ghost'), ctx: bare } as unknown as Agent - expect(() => bare.activities.open({ kind: 'bash', label: 'x', owner: ghost })) - .toThrow(/activity ownership requires the agent registry/) - }) - - it('rejects an owner that is not the registered agent instance', async () => { - const ctx = await harness() - const ghost = stubAgent(ctx, 'ghost') - expect(() => ctx.activities.open({ kind: 'bash', label: 'x', owner: ghost })) - .toThrow(/not the registered agent instance/) - }) - - it('carries owner session and correlation into snapshots', async () => { - const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const handle = ctx.activities.open({ - kind: 'bash', - label: 'npm test', - owner, - correlation: { jobId: 'bash-9' as never, callId: 'call-3' as never }, - }) - const snapshot = ctx.activities.get(handle.id, owner) - expect(snapshot.ownerSession).toBe(owner.id) - expect(snapshot.correlation).toEqual({ jobId: 'bash-9', callId: 'call-3' }) - expect(snapshot.status).toBe('running') - }) -}) - -describe('LocalActivityRegistry output', () => { - it('appends advance absolute offsets and reads are non-consuming', async () => { - const ctx = await harness() - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('hello ') - handle.append('world', { channel: 'stderr' }) - const first = ctx.activities.read(handle.id, 0) - expect(first.lossy).toBe(false) - expect(first.next).toBe(11) - expect(first.chunks).toEqual([ - { at: 0, text: 'hello ' }, - { at: 6, text: 'world', channel: 'stderr' }, - ]) - // A second identical read proves nothing was consumed. - expect(ctx.activities.read(handle.id, 0)).toEqual(first) - // Resuming from `next` yields nothing until more output arrives. - expect(ctx.activities.read(handle.id, first.next).chunks).toEqual([]) - handle.append('!') - expect(ctx.activities.read(handle.id, first.next).chunks).toEqual([{ at: 11, text: '!' }]) - const snapshot = ctx.activities.get(handle.id) - expect(snapshot.outputTotal).toBe(12) - expect(snapshot.outputEarliest).toBe(0) - }) - - it('drops an empty append without waking observers', async () => { - const ctx = await harness() - const signals: ActivityId[] = [] - ctx.activities.onOutput((id) => { signals.push(id) }) - const handle = ctx.activities.open({ kind: 'bash', label: 'quiet' }) - handle.append('') - expect(signals).toEqual([]) - expect(ctx.activities.get(handle.id).outputTotal).toBe(0) - }) - - it('rejects a negative or fractional read offset', async () => { - const ctx = await harness() - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - expect(() => ctx.activities.read(handle.id, -1)).toThrow(/invalid read offset/) - expect(() => ctx.activities.read(handle.id, 0.5)).toThrow(/invalid read offset/) - }) - - it('evicts whole head chunks past the live cap and flags stale readers lossy', async () => { - const ctx = await harness({ retainBytes: 8 }) - const handle = ctx.activities.open({ kind: 'bash', label: 'spam' }) - handle.append('aaaa') - handle.append('bbbb') - handle.append('cccc') - const read = ctx.activities.read(handle.id, 0) - expect(read.lossy).toBe(true) - expect(read.chunks).toEqual([{ at: 4, text: 'bbbb' }, { at: 8, text: 'cccc' }]) - expect(read.next).toBe(12) - const snapshot = ctx.activities.get(handle.id) - expect(snapshot.outputEarliest).toBe(4) - expect(snapshot.outputTotal).toBe(12) - // A reader at the retained boundary is not lossy. - expect(ctx.activities.read(handle.id, 4).lossy).toBe(false) - }) - - it('keeps only the UTF-8-safe tail of a single oversized chunk', async () => { - const ctx = await harness({ retainBytes: 5 }) - const handle = ctx.activities.open({ kind: 'bash', label: 'wide' }) - // '你好' is six UTF-8 bytes; a five-byte budget must not split the second code point. - handle.append('你好') - const read = ctx.activities.read(handle.id, 0) - expect(read.lossy).toBe(true) - expect(read.chunks).toEqual([{ at: 3, text: '好', gapBefore: true }]) - const snapshot = ctx.activities.get(handle.id) - expect(snapshot.outputEarliest).toBe(3) - expect(snapshot.outputTotal).toBe(6) - }) - - it('returns the whole overlapped chunk for a foreign mid-chunk offset', async () => { - const ctx = await harness() - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('abcdef') - const read = ctx.activities.read(handle.id, 3) - expect(read.chunks).toEqual([{ at: 0, text: 'abcdef' }]) - expect(read.lossy).toBe(false) - }) - - it('preserves a producer-reported gap marker through reads', async () => { - const ctx = await harness() - const handle = ctx.activities.open({ kind: 'bash', label: 'lossy source' }) - handle.append('tail after drop', { gapBefore: true, channel: 'stdout' }) - expect(ctx.activities.read(handle.id, 0).chunks).toEqual([ - { at: 0, text: 'tail after drop', channel: 'stdout', gapBefore: true }, - ]) - }) -}) - -describe('LocalActivityRegistry settlement', () => { - it('records the first terminal outcome and trims retention to the settled cap', async () => { - const ctx = await harness({ retainBytes: 1024, settledRetainBytes: 4 }) - const handle = ctx.activities.open({ kind: 'bash', label: 'build' }) - handle.append('abcdefgh') - handle.end({ status: 'completed', detail: 'exit code: 0' }) - const snapshot = ctx.activities.get(handle.id) - expect(snapshot.status).toBe('completed') - expect(snapshot.detail).toBe('exit code: 0') - expect(snapshot.finishedAt).toBeDefined() - const read = ctx.activities.read(handle.id, 0) - expect(read.lossy).toBe(true) - expect(read.chunks).toEqual([{ at: 4, text: 'efgh', gapBefore: true }]) - }) - - it('is first-wins: a later end and post-end writes are dropped without throwing', async () => { - const ctx = await harness() - const warn = vi.fn() - ctx.logger.warn = warn as never - const handle = ctx.activities.open({ kind: 'bash', label: 'build' }) - handle.end({ status: 'killed', detail: 'owner disposed' }) - handle.end({ status: 'completed' }) - handle.append('late') - handle.updateDetail('late detail') - const snapshot = ctx.activities.get(handle.id) - expect(snapshot.status).toBe('killed') - expect(snapshot.detail).toBe('owner disposed') - expect(snapshot.outputTotal).toBe(0) - expect(warn.mock.calls.map(call => String(call[0]))).toEqual([ - expect.stringContaining('append to ended activity'), - expect.stringContaining('detail update on ended activity'), - ]) - }) - - it('signals observers for appends and once more at settlement', async () => { - const ctx = await harness() - const signals: ActivityId[] = [] - ctx.activities.onOutput((id) => { signals.push(id) }) - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('a') - handle.append('b') - handle.end({ status: 'completed' }) - expect(signals).toEqual([handle.id, handle.id, handle.id]) - }) - - it('updateDetail changes the visible row without touching output offsets', async () => { - const ctx = await harness() - const changes: (Agent | undefined)[] = [] - ctx.activities.onActivitiesChanged((owner) => { changes.push(owner) }) - const handle = ctx.activities.open({ kind: 'workflow', label: 'docs run' }) - handle.updateDetail('3/10 agents done') - expect(ctx.activities.get(handle.id).detail).toBe('3/10 agents done') - expect(ctx.activities.get(handle.id).outputTotal).toBe(0) - expect(changes).toHaveLength(2) - }) -}) - -describe('LocalActivityRegistry visibility and delivery', () => { - it('fences owned processes by session id and keeps unowned ones open', async () => { - const ctx = await harness() - const alice = stubAgent(ctx, 'alice') - const bob = stubAgent(ctx, 'bob') - ctx.agents.register(alice) - ctx.agents.register(bob) - const owned = ctx.activities.open({ kind: 'bash', label: 'alice job', owner: alice }) - const open = ctx.activities.open({ kind: 'bash', label: 'shared' }) - owned.append('secret') - expect(ctx.activities.list(alice).map(snapshot => snapshot.id)).toEqual([owned.id, open.id]) - expect(ctx.activities.list(bob).map(snapshot => snapshot.id)).toEqual([open.id]) - expect(ctx.activities.list().map(snapshot => snapshot.id)).toEqual([open.id]) - expect(() => ctx.activities.get(owned.id, bob)).toThrow(/belongs to another session/) - expect(() => ctx.activities.read(owned.id, 0)).toThrow(/belongs to another session/) - expect(textFrom(ctx, open.id)).toBe('') - expect(ctx.activities.read(owned.id, 0, alice).chunks[0]?.text).toBe('secret') - }) - - it('delivers scoped listeners only their own agents and global listeners everything', async () => { - const ctx = await harness() - const alice = stubAgent(ctx, 'alice') - const bob = stubAgent(ctx, 'bob') - ctx.agents.register(alice) - ctx.agents.register(bob) - const globalOwners: (string | undefined)[] = [] - ctx.activities.onActivitiesChanged((owner) => { globalOwners.push(owner?.id) }) - const scopedOwners: (string | undefined)[] = [] - await alice.ctx.plugin({ - inject: ['activities'], - apply(scopedCtx: Context) { - scopedCtx.activities.onActivitiesChanged((owner) => { scopedOwners.push(owner?.id) }) - }, - }) - const scopedOutputs: string[] = [] - const scopedFiber = await alice.ctx.plugin({ - inject: ['activities'], - apply(scopedCtx: Context) { - scopedCtx.activities.onOutput((id) => { scopedOutputs.push(String(id)) }) - }, - }) - const aliceHandle = ctx.activities.open({ kind: 'bash', label: 'alice work', owner: alice }) - aliceHandle.append('scoped delivery probe') - expect(scopedOutputs).toEqual([String(aliceHandle.id)]) - ctx.activities.open({ kind: 'bash', label: 'bob work', owner: bob }) - ctx.activities.open({ kind: 'bash', label: 'unowned' }) - expect(globalOwners).toEqual(['alice', 'bob', undefined]) - expect(scopedOwners).toEqual(['alice']) - // Disposing one scoped registrant probes layer reclamation; the sibling - // scoped changed-listener keeps the layer alive and keeps delivering. - await scopedFiber.dispose() - ctx.activities.open({ kind: 'bash', label: 'alice later', owner: alice }) - expect(scopedOwners).toEqual(['alice', 'alice']) - - // A scope whose only contribution goes empties on disposal and is reclaimed. - const bobFiber = await bob.ctx.plugin({ - inject: ['activities'], - apply(scopedCtx: Context) { - scopedCtx.activities.onOutput(() => {}) - }, - }) - await bobFiber.dispose() - }) - - it('contains throwing listeners without breaking the commit', async () => { - const ctx = await harness() - const warn = vi.fn() - ctx.logger.warn = warn as never - ctx.activities.onActivitiesChanged(() => { throw new Error('changed boom') }) - ctx.activities.onOutput(() => { throw new Error('output boom') }) - const seen: (Agent | undefined)[] = [] - ctx.activities.onActivitiesChanged((owner) => { seen.push(owner) }) - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('x') - expect(seen).toHaveLength(1) - expect(ctx.activities.get(handle.id).outputTotal).toBe(1) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('onActivitiesChanged listener threw')) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('onOutput listener threw')) - }) - - it('unregisters listeners with their registering fiber', async () => { - const ctx = await harness() - const signals: ActivityId[] = [] - const fiber = ctx.plugin({ - inject: ['activities'], - apply(pluginCtx: Context) { - pluginCtx.activities.onOutput((id) => { signals.push(id) }) - }, - }) - await fiber - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('a') - expect(signals).toHaveLength(1) - await fiber.dispose() - handle.append('b') - expect(signals).toHaveLength(1) - }) -}) - -describe('LocalActivityRegistry lifecycle', () => { - it('owner disposal ends open records, removes rows, and announces the removal', async () => { - const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const handle = ctx.activities.open({ kind: 'bash', label: 'sleep', owner }) - handle.append('partial') - const settledEarly = ctx.activities.open({ kind: 'bash', label: 'done already', owner }) - settledEarly.end({ status: 'completed' }) - const changes: (string | undefined)[] = [] - ctx.activities.onActivitiesChanged((changed) => { changes.push(changed?.id) }) - await disposeAgentScope(owner) - expect(ctx.activities.list(owner)).toEqual([]) - expect(() => ctx.activities.get(handle.id, owner)).toThrow(/unknown activity/) - // The forced end and the removal both announced under the owner. - expect(changes.length).toBeGreaterThanOrEqual(2) - expect(new Set(changes)).toEqual(new Set(['owner'])) - // The producer's own late report is dropped silently, not thrown. - expect(() => { handle.end({ status: 'completed' }) }).not.toThrow() - }) - - it('service disposal ends everything, clears the store, and announces emptied owners', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const registryFiber = ctx.plugin(LocalActivityRegistry, {}) - await registryFiber - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const processes = ctx.activities - const owned = processes.open({ kind: 'bash', label: 'owned', owner }) - processes.open({ kind: 'bash', label: 'unowned' }) - const preSettled = processes.open({ kind: 'bash', label: 'already settled' }) - preSettled.end({ status: 'completed' }) - const changes: (string | undefined)[] = [] - ctx.activities.onActivitiesChanged((changed) => { changes.push(changed?.id) }) - await registryFiber.dispose() - expect(changes).toEqual(expect.arrayContaining(['owner', undefined])) - expect(() => { owned.append('late') }).not.toThrow() - }) -}) - -describe('LocalActivityRegistry snapshots', () => { - it('hands out fresh snapshots, never live registry state', async () => { - const ctx = await harness() - const handle: ActivityHandle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - const before: ActivitySnapshot = ctx.activities.get(handle.id) - handle.append('grow') - expect(before.outputTotal).toBe(0) - expect(ctx.activities.get(handle.id).outputTotal).toBe(4) - }) -}) diff --git a/packages/activity/activity-local/tests/loader-composition.spec.ts b/packages/activity/activity-local/tests/loader-composition.spec.ts deleted file mode 100644 index 4b341f7e16..0000000000 --- a/packages/activity/activity-local/tests/loader-composition.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import Include from '@deepseek-ai/cordis-plugin-include' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' - -let root: string | undefined -let context: Context | undefined - -afterEach(async () => { - await context?.fiber.dispose() - context = undefined - if (root !== undefined) await rm(root, { recursive: true, force: true }) - root = undefined -}) - -describe('process-local through a real Loader composition', () => { - it('applies the provider-owned retention config from a Cordis row', async () => { - root = await mkdtemp(join(tmpdir(), 'dsh-activity-local-loader-')) - const configPath = join(root, 'cordis.yml') - await writeFile(configPath, [ - "- name: '@deepseek-ai/dsh-activity-local'", - ' config:', - ' retainBytes: 8', - '', - ].join('\n')) - - context = new Context() - context.baseUrl = pathToFileURL(root).href + '/' - await context.plugin(Loader) - context.loader.builtins.include = Include - context.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (specifier === '@deepseek-ai/dsh-activity-local') return LocalActivityRegistry - throw new Error(`unexpected Loader import: ${specifier}`) - }, - } as unknown as NonNullable - await context.loader.create({ - name: 'cordis:include', - config: { path: pathToFileURL(configPath).href }, - }) - await context.loader.await() - - expect(context.activities).toBeInstanceOf(LocalActivityRegistry) - const handle = context.activities.open({ kind: 'bash', label: 'loader retention probe' }) - handle.append('aaaa') - handle.append('bbbb') - handle.append('cccc') - const read = context.activities.read(handle.id, 0) - // retainBytes: 8 from the Cordis row dropped the oldest chunk. - expect(read.lossy).toBe(true) - expect(read.chunks.map(chunk => chunk.text)).toEqual(['bbbb', 'cccc']) - expect(read.next).toBe(12) - }) -}) diff --git a/packages/activity/activity-local/tsconfig.json b/packages/activity/activity-local/tsconfig.json deleted file mode 100644 index 0bddbff854..0000000000 --- a/packages/activity/activity-local/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/scope" - }, - { - "path": "../activity" - }, - { - "path": "../../runtime-diagnostics/invariants" - } - ] -} diff --git a/packages/activity/activity/README.i18n.yaml b/packages/activity/activity/README.i18n.yaml deleted file mode 100644 index 14d65fd03c..0000000000 --- a/packages/activity/activity/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/activity/activity/README.md -README.md: 466bdf5aac8718b197495f477f012e6314e41dd6 -README.zh.md: 32dea93f72f12ae1f439220193b8fa5b994bbe45 diff --git a/packages/activity/activity/README.md b/packages/activity/activity/README.md deleted file mode 100644 index 466bdf5aac..0000000000 --- a/packages/activity/activity/README.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -description: "The streaming-output observation contract (ctx.activities): open/append/end handles, non-consuming offset reads, owner fencing, and the shared pull pump for producers and consumers of live activity output." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-activity - -English | [中文](README.zh.md) - -## Summary - -`dsh-activity` lets a long-running producer publish live output and status once, and any number of independent consumers read it without stealing bytes from the model: reads are non-consuming, addressed by absolute UTF-8 byte offsets that survive eviction, and fenced to the owning session. The registry is a pure observation plane — it starts nothing, cancels nothing, and is invisible to the model, so producers keep their execution resources and their existing model-facing surfaces. This package ships the contract only; load [`dsh-activity-local`](../activity-local/README.md) to get a working registry, and without one `ctx.activities` does not exist and observation silently degrades to none. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Use this package when you write a producer that mirrors live work, or a consumer that renders it; the composition mounts an implementation row (`@deepseek-ai/dsh-activity-local`), never this contract package directly. - -### Producing an activity - -`open(spec): ActivityHandle` validates the kind, label, and exact live owner, then atomically registers the record and returns the producer face: synchronous `append(text, { channel?, gapBefore? })`, `updateDetail(detail)`, and first-wins `end(outcome)`. After `end` — including a teardown force-end — `append` and `updateDetail` log and drop instead of throwing, so a producer's trailing flush cannot break its own teardown. Producer plugins extend `ActivityKindMap` with their opaque id namespace and may link an activity to its tool call and background job through `ActivityCorrelation`. Access the registry with `ctx.get('activities')` and contain every observation failure: the plane is optional, and the observed work must never depend on it. - -### Consuming activities - -`get(id, caller?)` and `list(caller?)` return fresh snapshots; listing includes only caller-owned and unowned activities, and predictable ids make that fence the boundary. `read(id, from, caller?)` returns the retained chunks overlapping `[from, total)` plus the resume offset, without consuming anything; a reader below the oldest retained byte gets `lossy: true`, never an error. `onActivitiesChanged(listener)` observes visible-set changes so consumers re-read rather than accumulate deltas, and `onOutput(listener)` signals stream advancement carrying only the id; both deliver owner-relative and contain every listener. - -### Pumping a substrate - -`pumpActivityOutput(handle, sources, { pollMs, done })` is the shared pump for producers whose substrate is a non-consuming offset reader (the subprocess `readFrom(fromByte)` family): it copies labeled deltas at a bounded cadence, marks a lossy source read `gapBefore`, and drains once more after `done` settles without ever calling `end()` itself. - ------ - - -## Understand the implementation - -
        -Implementation internals — click to expand - -The package holds the abstract `ActivityRegistry` service (a direct mount throws, naming an implementation to load), the vocabulary types, the branded `ActivityId`, the pump, and the invariant companion. Offsets are absolute so an observer's cursor never depends on what other observers did, and eviction moves only the earliest retained byte — the lossy flag is how a late reader learns the head is gone. - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Abstract `ActivityRegistry` service and the direct-mount guard | -| [`src/types.ts`](src/types.ts) | Vocabulary: kinds, snapshots, handles, reads, listeners | -| [`src/brand.ts`](src/brand.ts) | Branded `ActivityId` (browser-safe leaf) | -| [`src/pump.ts`](src/pump.ts) | `pumpActivityOutput` pull-substrate pump | -| [`src/invariant.ts`](src/invariant.ts) | Snapshot invariants installed on the change feed | - -
        - ------ - - -## Further Exploration - -- [`dsh-activity-local`](../activity-local/README.md) — the in-memory implementation and its retention config. -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.md) — the subsystem reference: offsets, retention, listener delivery. -- [`dsh-api-activity-controller`](../../api/activity-controller/README.md) — the Remote streams serving this plane to browsers. -- [Observation-seam Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) — why the plane is non-consuming and model-invisible. - ------ - - -## Model Experience - -None, as the registry carries live observation state for humans and registers no prompt, tool, or session event; activity output becomes model-visible only through the producers' existing tool results and `ctx.jobs` reads. - -#### KV Cache effect - -None; the package never assembles or alters provider requests. - -## Known Limitations and Deferred Work - - - -These limits define when the contract is a poor fit. They are current package constraints, not a task backlog. - -- **Records are process-local and live-only** — a Host restart empties every roster while the transcript keeps the producing tool cards; durable replay is a separate design. -- **The observation plane has no cancellation verb** — stopping work stays with the producers and `ctx.jobs`; a panel-initiated kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). -- **Settled records last until owner disposal** — retention trims their buffers to the settled cap, but the rows themselves are not aged out. - - -### Dev Note - -
        -Working context for maintainers — click to expand - -None. - -
        diff --git a/packages/activity/activity/README.zh.md b/packages/activity/activity/README.zh.md deleted file mode 100644 index 32dea93f72..0000000000 --- a/packages/activity/activity/README.zh.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -description: "流式输出观察契约(ctx.activities):open/append/end 句柄、非消费偏移读取、owner 栅栏,以及供实时活动输出生产者与消费者共用的拉取泵。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-activity - -[English](README.md) | 中文 - -## 概述 - -`dsh-activity` 让长时运行的生产者只发布一次实时输出与状态,任意数量的独立消费者读取它而不会偷走模型的字节:读取是非消费的,以跨淘汰仍有效的绝对 UTF-8 字节偏移寻址,并以归属会话为栅栏。注册表是纯观察面——它什么都不启动、什么都不取消、对模型不可见,因此生产者保留自己的执行资源与既有模型可见表面。本包只交付契约;加载 [`dsh-activity-local`](../activity-local/README.zh.md) 获得可用注册表,没有实现时 `ctx.activities` 不存在,观察静默退化为无。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -编写镜像实时工作的生产者、或渲染它的消费者时使用本包;组合挂载的是实现行(`@deepseek-ai/dsh-activity-local`),从不直接挂载这个契约包。 - -### 生产一个 activity - -`open(spec): ActivityHandle` 校验 kind、label 与确切的活跃 owner,然后原子注册记录并返回生产者面:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)`,以及先到先赢的 `end(outcome)`。在 `end` 之后——包括 teardown 强制收尾——`append` 与 `updateDetail` 记日志后丢弃而非抛出,因此生产者的尾部 flush 不会破坏自己的 teardown。生产者插件以自己的不透明 id 命名空间扩展 `ActivityKindMap`,并可通过 `ActivityCorrelation` 把 activity 关联到其工具调用与后台 job。用 `ctx.get('activities')` 访问注册表并包住每个观察失败:该面是可选的,被观察的工作绝不能依赖它。 - -### 消费 activities - -`get(id, caller?)` 与 `list(caller?)` 返回新鲜快照;列表只含调用方拥有与无主的 activity,可预测的 id 使该栅栏成为边界。`read(id, from, caller?)` 返回与 `[from, total)` 重叠的留存 chunk 加恢复偏移,不消费任何东西;读到最旧留存字节之前的位置得到 `lossy: true`,绝不报错。`onActivitiesChanged(listener)` 观察可见集变化,消费者据此重读而非累积增量;`onOutput(listener)` 以仅含 id 的信号提示流推进;两者按 owner 相对投递并包住每个监听器。 - -### 泵送一个底座 - -`pumpActivityOutput(handle, sources, { pollMs, done })` 是底座为非消费偏移读取器(subprocess 的 `readFrom(fromByte)` 家族)的生产者的共用泵:它以有界节奏复制带标签的增量,把有损的源读取标为 `gapBefore`,并在 `done` 结算后再排空一次,自己从不调用 `end()`。 - ------ - - -## 理解实现 - -
        -实现内幕——点击展开 - -本包持有抽象 `ActivityRegistry` 服务(直接挂载会抛出并指名应加载的实现)、词汇类型、branded `ActivityId`、泵与 invariant 伴生。偏移是绝对的,观察者的游标从不依赖其他观察者做了什么;淘汰只前移最旧留存字节——lossy 标志就是迟到读者得知头部已丢的方式。 - -| 文件 | 角色 | -|---|---| -| [`src/index.ts`](src/index.ts) | 抽象 `ActivityRegistry` 服务与直接挂载防护 | -| [`src/types.ts`](src/types.ts) | 词汇:kind、快照、句柄、读取、监听器 | -| [`src/brand.ts`](src/brand.ts) | branded `ActivityId`(浏览器安全叶子) | -| [`src/pump.ts`](src/pump.ts) | `pumpActivityOutput` 拉取底座泵 | -| [`src/invariant.ts`](src/invariant.ts) | 装在变化流上的快照不变式 | - -
        - ------ - - -## 进一步探索 - -- [`dsh-activity-local`](../activity-local/README.zh.md)——内存实现及其留存配置。 -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.zh.md)——子系统参考:偏移、留存、监听器投递。 -- [`dsh-api-activity-controller`](../../api/activity-controller/README.zh.md)——把该面提供给浏览器的 Remote 流。 -- [观察面 Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)——该面为何是非消费且模型不可见的。 - ------ - - -## 模型体验 - -无:注册表为人承载实时观察状态,不注册任何 prompt、工具或会话事件;activity 输出只经由生产者既有的工具结果与 `ctx.jobs` 读取才对模型可见。 - -#### KV Cache 影响 - -无;本包从不组装或改动 provider 请求。 - -## 已知限制与延期工作 - - - -这些限制说明契约何时不合适。它们是当前包约束,不是任务积压。 - -- **记录是进程本地且仅存活期**——Host 重启清空所有 roster,而 transcript 保留生产方的工具卡片;持久回放是另一项设计。 -- **观察面没有取消动词**——停止工作仍属于生产者与 `ctx.jobs`;面板发起的 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。 -- **已结算记录存续到 owner 释放**——留存把它们的缓冲裁到 settled 上限,但行本身不会被老化清除。 - - -### 开发备注 - -
        -维护者的工作上下文——点击展开 - -无。 - -
        diff --git a/packages/activity/activity/package.json b/packages/activity/activity/package.json deleted file mode 100644 index 89372dcf7d..0000000000 --- a/packages/activity/activity/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-activity", - "description": "Streaming-output observation registry (ctx.activities) for the DeepSeek Harness — non-consuming offset reads, bounded retention, and change signals over any long-running producer's live output", - "version": "0.1.2-alpha.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/activity/activity" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./brand": { - "types": "./lib/types/brand.d.ts", - "default": "./lib/types/brand.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.js", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - } -} diff --git a/packages/activity/activity/src/brand.ts b/packages/activity/activity/src/brand.ts deleted file mode 100644 index 8df786ffb9..0000000000 --- a/packages/activity/activity/src/brand.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * dsh-activity' owned branded id, carried across the registry, the observation - * wire, and correlation metadata on other domains' rows. - * - * It lives in its own leaf because the package root reaches `dsh-agent` - * through the owner and listener signatures, which a Client program cannot - * resolve even as a type. A browser-safe consumer imports the id here; - * `Branded` itself comes from the zero-dependency `@deepseek-ai/dsh-brand`. - * - * @module @deepseek-ai/dsh-activity/brand - */ - -import type { Branded } from '@deepseek-ai/dsh-brand' - -/** - * Identifies one observable activity. The registry generates `-N`; - * predictable ids rely on owner authorization rather than secrecy. - */ -export type ActivityId = Branded<'ActivityId'> - -/** - * Brand a string as a {@link ActivityId}. - * @param id - the raw process-id string (the registry generates `-N`). - * @returns the same string, branded; no validation is performed. - */ -export function ActivityId(id: string): ActivityId { - return id as ActivityId -} diff --git a/packages/activity/activity/src/index.ts b/packages/activity/activity/src/index.ts deleted file mode 100644 index 4e3927abe5..0000000000 --- a/packages/activity/activity/src/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * The streaming-output observation Service Definition (`ctx.activities`). An - * activity is one observable unit of long-running work: an append-only bounded - * output stream plus live status, readable by any number of independent - * observers at absolute byte offsets. The registry is a pure observation - * plane — it starts nothing, cancels nothing, and is invisible to the model; - * producers keep their execution resources and their existing model-facing - * surfaces (`ctx.jobs` cursors, tool results). The process-local registry - * lives in `@deepseek-ai/dsh-activity-local`. - * @module @deepseek-ai/dsh-activity - */ - -import { Context, Service } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { - ActivitiesChangedListener, ActivityHandle, ActivityId, ActivityOpen, ActivityOutputListener, - ActivityRead, ActivitySnapshot, -} from './types.ts' - -export { ActivityId } from './brand.ts' -export type { - ActivityAppendOptions, - ActivityChannel, - ActivityCorrelation, - ActivitiesChangedListener, - ActivityHandle, - ActivityKind, - ActivityKindMap, - ActivityOpen, - ActivityOutcome, - ActivityOutputChunk, - ActivityOutputListener, - ActivityRead, - ActivitySnapshot, - ActivityStatus, -} from './types.ts' -export { pumpActivityOutput } from './pump.ts' -export type { ActivityPumpOptions, ActivityPumpRead, ActivityPumpSource } from './pump.ts' - -declare module '@deepseek-ai/cordis' { - interface Context { - activities: ActivityRegistry - } -} - -/** - * Abstract streaming-output registry. Subclass, implement the abstract - * methods, and load the subclass as a plugin — it registers as - * `ctx.activities` (one implementation per context; loading a second throws, - * which is cordis' standard duplicate-service behavior). - * - * Implementations must honor these semantics: - * - Observation never consumes. Any number of readers hold their own absolute - * byte offsets; a read changes no cursor and no producer state, so the - * model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. - * - Records outlive producer fibers. Owner disposal ends still-open records - * and removes them; service disposal ends and clears everything. Neither - * awaits a producer — the registry owns no execution resource. - * - Retention is bounded. Appends past the live cap drop the oldest retained - * bytes; a reader below the retained window gets a lossy read, never an - * error. Settlement trims retention to the settled cap. - * - Listener delivery is owner-relative: a listener registered from an - * unscoped context — a host composition's own carrier — sees every owner, - * while one registered under an agent composition's scope sees exactly the - * agents composed under it. Every listener is contained. - */ -export abstract class ActivityRegistry extends Service { - constructor(ctx: Context) { - // `abstract` erases at runtime, so a composition row naming this package - // would register a ctx.activities with no method implementations and fail - // far from the misconfiguration. Fail loud at load instead. - if (new.target === ActivityRegistry) { - throw new Error('@deepseek-ai/dsh-activity is the abstract activity observation seam; load an implementation such as @deepseek-ai/dsh-activity-local instead') - } - super(ctx, 'activities') - } - - /** - * Validate the spec, attach owner cleanup, and atomically register one - * activity. A rejection leaves no id; after return the producer owns the - * handle and the visible set has changed. - * @param spec - activity identity, owner, and correlation. - * @returns the producer face of the registered activity. - */ - abstract open(spec: ActivityOpen): ActivityHandle - - /** - * List caller-owned and unowned activities in registration order without - * exposing another session's labels or output. - * @param caller - reading agent; a non-agent caller sees only unowned activities. - * @returns fresh snapshots. - */ - abstract list(caller?: Agent): ActivitySnapshot[] - - /** - * Return a fresh snapshot. Throws for an unknown or foreign activity. - * @param id - activity to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ - abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot - - /** - * Read retained output from an absolute byte offset without consuming it. - * Resume by passing a previous read's `next`; a foreign offset inside a - * retained chunk returns that whole chunk (its `at` may precede `from`). - * Throws for an unknown or foreign activity or a negative or non-integer - * offset. - * @param id - activity to read. - * @param from - absolute byte offset to read from (0 for the retained head). - * @param caller - reading agent checked against the owner. - * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. - */ - abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead - - /** - * Register an effect-scoped observer of visible-set changes: opening, - * detail updates, settlement, owner-disposal removal, and the emptying that - * service disposal commits — so an observer re-reads rather than - * accumulating deltas. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned activity changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ - abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void - - /** - * Register an effect-scoped observer of stream advancement — one signal per - * committed append and one at settlement, carrying only the activity id. - * A consumer schedules a {@link read} from its own cursor; the registry - * never pushes payloads. Listeners are contained and never awaited. - * @param listener - receives the id whose stream advanced. - * @returns disposer that unregisters the listener. - */ - abstract onOutput(listener: ActivityOutputListener): () => void -} - -export default ActivityRegistry diff --git a/packages/activity/activity/src/invariant.ts b/packages/activity/activity/src/invariant.ts deleted file mode 100644 index 6753fa7b0e..0000000000 --- a/packages/activity/activity/src/invariant.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** Package-owned process-snapshot invariants. @module @deepseek-ai/dsh-activity/invariant */ - -import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import type { ActivitySnapshot } from './types.ts' - -const PACKAGE_NAME = '@deepseek-ai/dsh-activity' -const TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed']) - -/** Cordis companion plugin name. */ -export const name = 'activity-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** Validate the cross-field relationships in one registry snapshot. */ -/* jscpd:ignore-start -- mirrors the jobs invariant's id/ordinal checks by design; the two registries share the `-N` scheme. */ -function validateSnapshot(snapshot: ActivitySnapshot, fail: InvariantFailure): void { - const id = String(snapshot.id) - const prefix = `${snapshot.kind}-` - const ordinal = Number(id.slice(prefix.length)) - if (snapshot.kind.length === 0 || !id.startsWith(prefix) - || !Number.isSafeInteger(ordinal) || ordinal < 1) { - fail(`activity snapshot id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`) - } - if (snapshot.label.length === 0) fail(`activity ${JSON.stringify(id)} label must be non-empty`) - if (!Number.isSafeInteger(snapshot.startedAt) || snapshot.startedAt < 0) { - fail(`activity ${JSON.stringify(id)} startedAt must be a non-negative epoch integer`) - } - /* jscpd:ignore-end */ - - const terminal = TERMINAL_STATUSES.has(snapshot.status) - if (terminal !== (snapshot.finishedAt !== undefined)) { - fail(`activity ${JSON.stringify(id)} finishedAt must be present exactly for a terminal status`) - } - if (snapshot.finishedAt !== undefined - && (!Number.isSafeInteger(snapshot.finishedAt) || snapshot.finishedAt < snapshot.startedAt)) { - fail(`activity ${JSON.stringify(id)} finishedAt must be an epoch integer no earlier than startedAt`) - } - - if (!Number.isSafeInteger(snapshot.outputTotal) || snapshot.outputTotal < 0 - || !Number.isSafeInteger(snapshot.outputEarliest) || snapshot.outputEarliest < 0 - || snapshot.outputEarliest > snapshot.outputTotal) { - fail(`activity ${JSON.stringify(id)} output offsets must satisfy 0 <= outputEarliest <= outputTotal`) - } -} - -/** Install checks over current visible records and every later visible-set change. */ -const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - for (const snapshot of ctx.activities.list()) validateSnapshot(snapshot, fail) - ctx.activities.onActivitiesChanged((owner: Agent | undefined) => { - for (const snapshot of ctx.activities.list(owner)) validateSnapshot(snapshot, fail) - }) -}, { inject: ['activities'] }) - -/** - * Register the process-registry invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/activity/activity/src/pump.ts b/packages/activity/activity/src/pump.ts deleted file mode 100644 index 4cc1eae22d..0000000000 --- a/packages/activity/activity/src/pump.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Shared poll pump for producers whose output substrate is a non-consuming - * offset reader (the subprocess `readFrom(fromByte)` family). Pure utility — - * no cordis, no timers retained past settlement. - * @module - */ - -import type { ActivityChannel, ActivityHandle } from './types.ts' - -/** One non-consuming offset read from a producer-owned stream. */ -export interface ActivityPumpRead { - /** Text captured since the requested offset (the whole retained tail when lossy). */ - text: string - /** Whole-stream offset to resume from on the next read. */ - nextOffset: number - /** True when the requested offset slid out of the source's retained window. */ - lossy: boolean -} - -/** One producer-owned stream the pump copies into a process. */ -export interface ActivityPumpSource { - /** Stream label attached to every chunk this source yields. */ - channel?: ActivityChannel - /** - * Read everything captured since `fromByte` without consuming it. - * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). - * @returns the delta text, the next offset, and the lossy flag. - */ - read(fromByte: number): ActivityPumpRead -} - -/** Cadence and settlement for one {@link pumpActivityOutput} run. */ -export interface ActivityPumpOptions { - /** Poll interval in milliseconds; a positive finite number. */ - pollMs: number - /** - * Settles when the producer's work finished and its sources hold their - * final bytes. The pump drains once more after settlement and returns; - * a rejection is treated as settlement. - */ - done: Promise -} - -/** - * Copy producer streams into a process at a bounded cadence: drain every - * source, sleep `pollMs` or until `done` settles, repeat, then drain one - * final time. A lossy source read appends its surviving tail with - * `gapBefore`, so the discontinuity stays visible to observers. The pump - * never calls `handle.end()` — the producer maps its own outcome. - * @param handle - the open process receiving the copied chunks. - * @param sources - producer streams, each pumped at its own offset. - * @param options - poll cadence and the producer's settlement promise. - * @returns resolves after the final post-settlement drain. - */ -export async function pumpActivityOutput( - handle: ActivityHandle, - sources: readonly ActivityPumpSource[], - options: ActivityPumpOptions, -): Promise { - if (!Number.isFinite(options.pollMs) || options.pollMs <= 0) { - throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(options.pollMs)}`) - } - const states = sources.map(source => ({ source, cursor: 0 })) - const drain = (): void => { - for (const state of states) { - const { source } = state - const read = source.read(state.cursor) - state.cursor = read.nextOffset - if (read.text.length === 0) continue - if (source.channel === undefined && !read.lossy) { - handle.append(read.text) - } else { - handle.append(read.text, { - ...source.channel !== undefined ? { channel: source.channel } : {}, - ...read.lossy ? { gapBefore: true as const } : {}, - }) - } - } - } - let settled = false - const settlement = options.done.then(() => { settled = true }, () => { settled = true }) - while (!settled) { - drain() - await Promise.race([ - new Promise((resolve) => { - const timer = setTimeout(resolve, options.pollMs) - // The settlement branch wins the race; clear the loser so a finished - // pump holds no pending timer for up to one poll interval. - void settlement.then(() => { clearTimeout(timer); resolve() }) - }), - settlement, - ]) - } - drain() -} diff --git a/packages/activity/activity/src/types.ts b/packages/activity/activity/src/types.ts deleted file mode 100644 index 3ea1a42332..0000000000 --- a/packages/activity/activity/src/types.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SessionId } from '@deepseek-ai/dsh-session' -import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -import type { ActivityId } from './brand.ts' - -export type { ActivityId } from './brand.ts' - -/** - * Producer-defined activity kinds. Plugins extend this map by declaration - * merging; the registry treats every value as an opaque id namespace. - */ -export interface ActivityKindMap { - bash: 'bash' -} - -/** A registered activity kind — also the id prefix (`bash`, `workflow`, …). */ -export type ActivityKind = ActivityKindMap[keyof ActivityKindMap] - -/** Lifecycle state of one observable activity. */ -export type ActivityStatus = 'running' | 'completed' | 'failed' | 'killed' - -/** - * Output stream label. Producers without distinct streams omit it; consumers - * treat an unrecognized label like an absent one. - */ -export type ActivityChannel = 'stdout' | 'stderr' - -/** - * Links one activity to the records other domains keep for the same work, so a - * consumer can attach the output stream to an existing card or row without - * guessing from labels. - */ -export interface ActivityCorrelation { - /** Tool call that started the work, when a tool produced it. */ - callId?: ToolCallId - /** Background job carrying the model-facing control surface, when one exists. */ - jobId?: JobId -} - -/** - * Producer declaration passed to {@link ActivityRegistry.open}. The registry - * owns identity, retention, and observer delivery; the producer keeps its - * execution resources and reports through the returned {@link ActivityHandle}. - */ -export interface ActivityOpen { - /** Producer kind — also the id prefix. */ - kind: ActivityKind - /** One-line human-facing label (the command; the run description). */ - label: string - /** - * Owning live agent. Visibility is fenced by its session id, and agent - * disposal ends and removes the record. The instance must be the one - * currently registered under its agent id. Omitting the owner creates an - * unowned activity, visible to any caller until service disposal. - */ - owner?: Agent - /** Optional links to the tool call and background job for the same work. */ - correlation?: ActivityCorrelation -} - -/** Terminal outcome reported through {@link ActivityHandle.end}. */ -export interface ActivityOutcome { - /** How the work ended: finished (`completed`), broke (`failed`), or was cancelled (`killed`). */ - status: 'completed' | 'failed' | 'killed' - /** Kind-specific detail rendered into status lines ('exit code: 3'). */ - detail?: string -} - -/** Options for one {@link ActivityHandle.append}. */ -export interface ActivityAppendOptions { - /** Stream label for the chunk; omitted for producers without distinct streams. */ - channel?: ActivityChannel - /** - * Marks that producer-side bytes between the previous chunk and this one - * were lost (a source window slid before the producer could copy it), so a - * consumer can render the discontinuity instead of a silent splice. - */ - gapBefore?: true -} - -/** - * Producer face of one open activity. All methods are synchronous. After - * {@link end} — including a registry-forced end during owner or service - * teardown — `append` and `updateDetail` log and drop instead of throwing, so - * a producer's trailing flush cannot break its own teardown path. - */ -export interface ActivityHandle { - /** The registry-issued id (`-N`). */ - readonly id: ActivityId - /** - * Append one output chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. - * @param text - the chunk text, exactly as produced. - * @param options - stream label and gap marker. - */ - append(text: string, options?: ActivityAppendOptions): void - /** - * Replace the snapshot's status detail (a progress line such as `3/10`). - * @param detail - the new detail line. - */ - updateDetail(detail: string): void - /** - * Record the terminal outcome and trim retention to the settled cap. - * First-wins: a later call — the producer's own report after a teardown - * force-end, or a duplicate — is dropped silently. - * @param outcome - terminal status and optional detail. - */ - end(outcome: ActivityOutcome): void -} - -/** - * A read-only projection of one activity, safe to hand to listeners and - * consumers — a fresh object per call, never live registry state. - */ -export interface ActivitySnapshot { - /** The registry-issued id (`-N`). */ - id: ActivityId - /** The producer kind the process was opened with. */ - kind: ActivityKind - /** The producer-supplied one-line label. */ - label: string - /** Owner session id used for visibility and grouping; absent for unowned activities. */ - ownerSession?: SessionId - /** Links to the tool call and background job for the same work, when supplied. */ - correlation?: ActivityCorrelation - /** Current lifecycle state. */ - status: ActivityStatus - /** Kind-specific status detail, when the producer supplied one. */ - detail?: string - /** Epoch ms when the activity was opened. */ - startedAt: number - /** Epoch ms when the activity ended; absent while `running`. */ - finishedAt?: number - /** Total UTF-8 bytes ever appended — the offset the next chunk starts at. */ - outputTotal: number - /** - * Offset of the oldest retained byte. Greater than zero exactly when - * retention dropped the head; a reader starting below it gets a lossy read. - */ - outputEarliest: number -} - -/** One retained output chunk returned by {@link ActivityRegistry.read}. */ -export interface ActivityOutputChunk { - /** Absolute offset of the chunk's first byte. */ - at: number - /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ - text: string - /** Stream label, when the producer supplied one. */ - channel?: ActivityChannel - /** Producer-reported loss immediately before this chunk. */ - gapBefore?: true -} - -/** Result of one non-consuming {@link ActivityRegistry.read}. */ -export interface ActivityRead { - /** Retained chunks overlapping `[from, total)`, in offset order. */ - chunks: readonly ActivityOutputChunk[] - /** - * Offset to resume from — the process' current `outputTotal`. Always a - * chunk boundary: appends land whole and trimming only advances chunk - * starts, and consumers concatenate `chunks` under that assumption, so a - * provider serving partial chunks would silently duplicate text. - */ - next: number - /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ - lossy: boolean -} - -/** - * Observer of visible-set changes: opening, detail updates, settlement, and - * removal. Receives the owner whose set changed, or `undefined` when an - * unowned process changed and every caller's set did. Consumers re-read - * rather than accumulating deltas. - */ -export type ActivitiesChangedListener = (owner: Agent | undefined) => void - -/** - * Observer of stream advancement for one activity: new appended output, or the - * settlement that ends the stream. Carries no payload — a consumer schedules a - * {@link ActivityRegistry.read} from its own cursor. - */ -export type ActivityOutputListener = (id: ActivityId) => void diff --git a/packages/activity/activity/tests/invariant.spec.ts b/packages/activity/activity/tests/invariant.spec.ts deleted file mode 100644 index 306cd4533a..0000000000 --- a/packages/activity/activity/tests/invariant.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import ActivityRegistry, { ActivityId } from '@deepseek-ai/dsh-activity' -import type { ActivitiesChangedListener, ActivitySnapshot } from '@deepseek-ai/dsh-activity' -import * as ProcessInvariant from '@deepseek-ai/dsh-activity/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' - -const BASE: ActivitySnapshot = { - id: ActivityId('bash-1'), - kind: 'bash', - label: 'compile', - status: 'completed', - startedAt: 10, - finishedAt: 20, - outputTotal: 12, - outputEarliest: 4, -} - -const RUNNING: ActivitySnapshot = { - id: ActivityId('bash-1'), - kind: 'bash', - label: 'compile', - status: 'running', - startedAt: 10, - outputTotal: 0, - outputEarliest: 0, -} - -/** Boot the companion over a probe registry whose visible set the test controls. */ -async function setup(seed: ActivitySnapshot[] = []): Promise<(visible: ActivitySnapshot[], owner?: Agent) => void> { - const ctx = new Context() - let current = seed - let listener: ActivitiesChangedListener | undefined - const probe = { - list: () => current, - onActivitiesChanged(value: ActivitiesChangedListener) { - listener = value - return () => { listener = undefined } - }, - } as unknown as ActivityRegistry - await ctx.plugin(InvariantRegistry) - await ctx.plugin({ - name: 'activity-invariant-probe', - apply(child: Context) { child.provide('activities', probe) }, - }) - await ctx.plugin(ProcessInvariant) - if (listener === undefined) throw new Error('activity invariant did not subscribe to visible-set changes') - return (visible, owner) => { - current = visible - listener!(owner) - } -} - -describe('process-registry invariants', () => { - it('accepts coherent running and terminal snapshots', async () => { - const notify = await setup([RUNNING]) - expect(() => { notify([BASE]) }).not.toThrow() - expect(() => { notify([{ ...BASE, id: ActivityId('workflow-2'), kind: 'workflow' }]) }) - .not.toThrow() - }) - - it.each([ - [{ ...BASE, id: ActivityId('-1'), kind: '' as ActivitySnapshot['kind'] }, /positive ordinal/], - [{ ...BASE, id: ActivityId('other-1') }, /must be "bash-" followed by a positive ordinal/], - [{ ...BASE, id: ActivityId('bash-0') }, /positive ordinal/], - [{ ...BASE, label: '' }, /label must be non-empty/], - [{ ...BASE, startedAt: -1 }, /startedAt must be a non-negative epoch integer/], - [{ ...BASE, status: 'running' as const, finishedAt: 20 }, /finishedAt must be present exactly for a terminal status/], - [{ ...RUNNING, status: 'killed' as const }, /finishedAt must be present exactly for a terminal status/], - [{ ...BASE, finishedAt: 9 }, /no earlier than startedAt/], - [{ ...BASE, outputEarliest: 13 }, /0 <= outputEarliest <= outputTotal/], - [{ ...BASE, outputTotal: -1, outputEarliest: 0 }, /0 <= outputEarliest <= outputTotal/], - ] as const)('rejects an incoherent registry snapshot', async (snapshot, message) => { - const notify = await setup() - expect(() => { notify([snapshot]) }).toThrow(message) - }) - - it('rejects an incoherent record already present at installation', async () => { - await expect(setup([{ ...BASE, label: '' }])).rejects.toThrow(/label must be non-empty/) - }) -}) diff --git a/packages/activity/activity/tests/service.spec.ts b/packages/activity/activity/tests/service.spec.ts deleted file mode 100644 index 2dd685c1d3..0000000000 --- a/packages/activity/activity/tests/service.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' -import ActivityRegistry, { ActivityId, pumpActivityOutput } from '@deepseek-ai/dsh-activity' -import type { ActivityAppendOptions, ActivityHandle, ActivityPumpSource, ActivitySnapshot } from '@deepseek-ai/dsh-activity' - -/** Record every append so pump behavior is observable without a registry. */ -function recordingHandle(): { handle: ActivityHandle; appends: { text: string; options?: ActivityAppendOptions }[] } { - const appends: { text: string; options?: ActivityAppendOptions }[] = [] - const handle: ActivityHandle = { - id: ActivityId('bash-1'), - append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) }, - updateDetail() {}, - end() {}, - } - return { handle, appends } -} - -/** A scripted source: each read() shifts the next scripted result. */ -function scriptedSource( - reads: { text: string; lossy?: boolean }[], - channel?: 'stdout' | 'stderr', -): { source: ActivityPumpSource; offsets: number[] } { - const offsets: number[] = [] - let offset = 0 - const source: ActivityPumpSource = { - ...channel !== undefined ? { channel } : {}, - read(fromByte) { - offsets.push(fromByte) - const next = reads.shift() ?? { text: '' } - offset += Buffer.byteLength(next.text, 'utf8') - return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } - }, - } - return { source, offsets } -} - -describe('ActivityRegistry seam', () => { - it('refuses to mount the abstract Service Definition directly', async () => { - const ctx = new Context() - await expect(ctx.plugin(ActivityRegistry as unknown as () => void)) - .rejects.toThrow(/load an implementation such as @deepseek-ai\/dsh-activity-local/) - }) - - it('preserves the SessionId brand on public owner snapshots', () => { - expectTypeOf().toEqualTypeOf() - }) -}) - -describe('pumpActivityOutput', () => { - it('rejects a non-positive poll interval before touching any source', async () => { - const { handle } = recordingHandle() - await expect(pumpActivityOutput(handle, [], { pollMs: 0, done: Promise.resolve() })) - .rejects.toThrow(/invalid pump pollMs/) - }) - - it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => { - vi.useFakeTimers() - try { - const { handle, appends } = recordingHandle() - let settle!: () => void - const done = new Promise((resolve) => { settle = resolve }) - const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') - const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr') - const pump = pumpActivityOutput(handle, [out.source, err.source], { pollMs: 50, done }) - - await vi.advanceTimersByTimeAsync(50) - settle() - await pump - expect(appends).toEqual([ - { text: 'a', options: { channel: 'stdout' } }, - { text: 'bc', options: { channel: 'stdout' } }, - { text: 'E', options: { channel: 'stderr' } }, - ]) - // Every read resumed from the previous nextOffset, never from a shared cursor. - expect(out.offsets).toEqual([0, 1, 3]) - expect(err.offsets).toEqual([0, 0, 1]) - // Settlement cleared the raced poll timer instead of leaving it pending. - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('marks a lossy source read as a gap so observers see the discontinuity', async () => { - const { handle, appends } = recordingHandle() - const { source } = scriptedSource([{ text: 'tail', lossy: true }]) - await pumpActivityOutput(handle, [source], { pollMs: 1, done: Promise.resolve() }) - expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }]) - }) - - it('treats a rejected done as settlement and still drains the final bytes', async () => { - const { handle, appends } = recordingHandle() - const { source } = scriptedSource([{ text: 'last' }]) - await pumpActivityOutput(handle, [source], { pollMs: 1, done: Promise.reject(new Error('producer broke')) }) - expect(appends).toEqual([{ text: 'last' }]) - }) -}) diff --git a/packages/activity/activity/tsconfig.json b/packages/activity/activity/tsconfig.json deleted file mode 100644 index f4deee6327..0000000000 --- a/packages/activity/activity/tsconfig.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../util/brand" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/session" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../jobs/jobs" - }, - { - "path": "../../runtime-diagnostics/invariants" - } - ] -} diff --git a/packages/api/activity-controller/README.i18n.yaml b/packages/api/activity-controller/README.i18n.yaml deleted file mode 100644 index 85ebf647c1..0000000000 --- a/packages/api/activity-controller/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/api/activity-controller/README.md -README.md: ffdf97aa94ab808ea4f95a9b8b8a36e3e04f43ed -README.zh.md: 580d00a11d3e8743d606c9c9ca98c4553235cc50 diff --git a/packages/api/activity-controller/README.md b/packages/api/activity-controller/README.md deleted file mode 100644 index ffdf97aa94..0000000000 --- a/packages/api/activity-controller/README.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -description: "Activity Remote streams for browsers: the activity.control roster stream, per-activity activity.observe output streams with flushMs coalescing and maxFrameBytes budgets, and the ctx.activityFeed client model." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-api-activity-controller - -English | [中文](README.zh.md) - -## Summary - -`dsh-api-activity-controller` streams the live-activity plane to browsers: a client follows one roster stream for every visible task row and opens one observation stream per expanded panel, resuming across reconnects from its own cursor. A pure read surface — it starts nothing, cancels nothing, and never touches the model-facing consuming cursors; the wire path reads only the registry's non-consuming offsets. A composition without `ctx.activities` still serves an empty baseline, so the client degrades to no live rows rather than an error. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Mount the host row beside `dsh-typert-registry` and load the client half through the plugin manifest; UI features then consume `ctx.activityFeed` instead of raw streams. - -### The two Remote streams - -The Host `ActivityController` (namespace `activity`) exposes two `@Remote({ mode: 'stream' })` methods: - -- `control(signal)` — one complete roster baseline, then whole-bucket replacement frames per owner session on every registry change (the self-healing shape the session control stream uses for jobs). A composition without the registry serves an empty baseline. The Remote tier deliberately serves every session's rows to any connected browser, and `observe` satisfies the registry's owner fence with the mirrored owner: the per-session fence is an in-process contract, and this single-user local BFF reads across it exactly as the session control stream does for `jobsBySession`. -- `observe({ activityId, from? }, signal)` — one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget; one larger chunk ships whole), then one terminal `status` after the settled activity is drained, after which the generation closes normally. Reconnecting callers resume by passing the last frame's `next`; a resume behind the retained window arrives flagged `lossy`. Status rides the same stream as output, so settlement can never race a still-open output channel. - -| Config | Default | Meaning | -|---|---|---| -| `flushMs` | `100` | Coalescing window after new output before an observation read. | -| `maxFrameBytes` | `65536` | Soft byte budget per observation output frame. | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-activity-controller) is the exhaustive source for every accepted field. - -### The client feed - -The client half (`./client`) installs `ctx.activityFeed`: `ClientActivityModel` mirrors the roster (last-wins buckets, generation replace) and accumulates each observed activity's bounded render tail, and `ClientActivityFeed.observe(id)` opens reference-counted observation streams that resume from the model's cursor across carrier generations. - ------ - - -## Understand the implementation - -
        -Implementation internals — click to expand - -The Host side mirrors the registry into owner buckets (`ActivityFeed`), fans control frames out to per-generation followers, and runs each observation as an async generator that subscribes before its first read so no append lands between anchor and stream. The client side keeps observation state per activity with a resume cursor; release closures bind the exact entry they were minted for, so a superseded observation's late release can neither tear down nor clear its successor. - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | `ActivityController`: the two Remote stream methods | -| [`src/feed.ts`](src/feed.ts) | Owner-bucket roster mirror and control-stream followers | -| [`src/observe.ts`](src/observe.ts) | Per-activity observation generator: anchor, coalescing, terminal status | -| [`src/client/model.ts`](src/client/model.ts) | Roster and observation state with the bounded render tail | -| [`src/client/service.ts`](src/client/service.ts) | `ctx.activityFeed`: reference-counted observation streams | -| [`src/client/index.ts`](src/client/index.ts) | Client plugin: roster stream wiring and the service install | - -
        - ------ - - -## Further Exploration - -- [`dsh-activity`](../../activity/activity/README.md) — the observation contract this transport reads. -- [`dsh-client-ui-activity`](../../client/ui-activity/README.md) — the task list rendering `ctx.activityFeed`. -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.md) — the plane end to end, wire shapes included. - ------ - - -## Model Experience - -None, as activity transport is browser and Host observation state and registers no prompt, tool, or session event. - -#### KV Cache effect - -None; the package never assembles or alters provider requests. - -## Known Limitations and Deferred Work - - - -These limits define when the transport is a poor fit. They are current package constraints, not a task backlog. - -- **Registry presence is sampled at construction** — a registry mounted after the controller stays unobserved until the controller reloads. -- **The roster names no watermark** — an observation stream anchors itself; correlating roster rows against observation offsets is client policy. - - -### Dev Note - -
        -Working context for maintainers — click to expand - -None. - -
        diff --git a/packages/api/activity-controller/README.zh.md b/packages/api/activity-controller/README.zh.md deleted file mode 100644 index 580d00a11d..0000000000 --- a/packages/api/activity-controller/README.zh.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -description: "面向浏览器的 Activity Remote 流:activity.control roster 流、带 flushMs 合并与 maxFrameBytes 预算的逐活动 activity.observe 输出流,以及 ctx.activityFeed 客户端模型。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-api-activity-controller - -[English](README.md) | 中文 - -## 概述 - -`dsh-api-activity-controller` 把实时活动面流式提供给浏览器:客户端跟随一条 roster 流获得每个可见任务行,并为每个展开的面板打开一条观察流,重连后从自己的游标续传。它是纯读表面——什么都不启动、什么都不取消、从不触碰模型可见的消费游标;线路路径只读注册表的非消费偏移。没有 `ctx.activities` 的组合仍提供空 baseline,客户端退化为没有实时行而非报错。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -在 `dsh-typert-registry` 旁挂载宿主行,并经插件清单加载客户端半侧;UI 功能随即消费 `ctx.activityFeed` 而非裸流。 - -### 两条 Remote 流 - -宿主 `ActivityController`(命名空间 `activity`)暴露两个 `@Remote({ mode: 'stream' })` 方法: - -- `control(signal)`——一份完整 roster baseline,之后每次注册表变化按 owner 会话推送整桶替换帧(与 session control 流承载 jobs 的自愈形态一致)。没有注册表的组合返回空 baseline。Remote 层有意把所有会话的行提供给任何已连接的浏览器,`observe` 以镜像到的 owner 替调用方满足注册表的 owner 栅栏:按会话的栅栏是进程内契约,这个单用户本地 BFF 跨栅栏读取,与 session control 流对 `jobsBySession` 的做法完全一致。 -- `observe({ activityId, from? }, signal)`——一帧 `opened` 锚点、合并后的 `output` 帧(`flushMs` 窗口,`maxFrameBytes` 软预算;单个更大的 chunk 整块发出),已结算活动排空后一帧终态 `status`,随后本代正常关闭。重连调用方传上一帧的 `next` 续传;落后于留存窗口的续传带 `lossy` 标记到达。status 与 output 走同一条流,结算绝不会与仍开放的输出通道赛跑。 - -| 配置 | 默认 | 含义 | -|---|---|---| -| `flushMs` | `100` | 新输出后进行一次观察读取前的合并窗口。 | -| `maxFrameBytes` | `65536` | 每个观察输出帧的软字节预算。 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-activity-controller)是每个可接受字段的穷尽来源。 - -### 客户端 feed - -客户端半侧(`./client`)安装 `ctx.activityFeed`:`ClientActivityModel` 镜像 roster(last-wins 桶、按代整体替换)并累积每个被观察活动的有界渲染尾部,`ClientActivityFeed.observe(id)` 打开引用计数的观察流,跨 carrier 代从模型游标续传。 - ------ - - -## 理解实现 - -
        -实现内幕——点击展开 - -宿主侧把注册表镜像进 owner 桶(`ActivityFeed`),向每代 follower 扇出 control 帧,并把每个观察跑成先订阅再首读的 async generator,锚点与流之间不会漏 append。客户端侧按活动保存观察状态与续传游标;release 闭包绑定自己创建时的那个条目,被替代观察的迟到 release 既拆不掉也清不掉后继者。 - -| 文件 | 角色 | -|---|---| -| [`src/index.ts`](src/index.ts) | `ActivityController`:两个 Remote 流方法 | -| [`src/feed.ts`](src/feed.ts) | owner 桶 roster 镜像与 control 流 follower | -| [`src/observe.ts`](src/observe.ts) | 逐活动观察 generator:锚点、合并、终态 status | -| [`src/client/model.ts`](src/client/model.ts) | roster 与观察状态,含有界渲染尾部 | -| [`src/client/service.ts`](src/client/service.ts) | `ctx.activityFeed`:引用计数的观察流 | -| [`src/client/index.ts`](src/client/index.ts) | 客户端插件:roster 流接线与服务安装 | - -
        - ------ - - -## 进一步探索 - -- [`dsh-activity`](../../activity/activity/README.zh.md)——本传输所读的观察契约。 -- [`dsh-client-ui-activity`](../../client/ui-activity/README.zh.md)——渲染 `ctx.activityFeed` 的任务列表。 -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.zh.md)——端到端的观察面,含线路形态。 - ------ - - -## 模型体验 - -无:activity 传输是浏览器与宿主的观察状态,不注册任何 prompt、工具或会话事件。 - -#### KV Cache 影响 - -无;本包从不组装或改动 provider 请求。 - -## 已知限制与延期工作 - - - -这些限制说明该传输何时不合适。它们是当前包约束,不是任务积压。 - -- **注册表存在性在构造时采样**——controller 之后才挂载的注册表在 controller 重载前保持不被观察。 -- **roster 不命名水位**——观察流自行锚定;把 roster 行与观察偏移相关联是客户端策略。 - - -### 开发备注 - -
        -维护者的工作上下文——点击展开 - -无。 - -
        diff --git a/packages/api/activity-controller/package.json b/packages/api/activity-controller/package.json deleted file mode 100644 index a5250ad8ac..0000000000 --- a/packages/api/activity-controller/package.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-api-activity-controller", - "description": "Activity Remote streams: the live-activity roster and per-activity non-consuming output observation", - "version": "0.1.2-alpha.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/api/activity-controller" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./types": { - "types": "./lib/types/types.d.ts", - "default": "./lib/types/types.js" - }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, - "./typert": { - "types": "./lib/typert.host.d.ts", - "default": "./lib/typert.host.js" - }, - "./remote": { - "types": "./lib/typert.remote-client.d.ts", - "default": "./lib/typert.remote-client.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "scripts": { - "bundle": "tsdown", - "watch": "tsdown --watch" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/client.js", - "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/typert.host.js", - "lib/typert.host.d.ts", - "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" - ], - "license": "MIT", - "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" - }, - "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", - "@deepseek-ai/dsh-activity-local": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" - }, - "dsh": { - "client": { - "external": [ - "@deepseek-ai/dsh-api-gateway/client" - ], - "inject": [ - "@deepseek-ai/dsh-api-gateway", - "@deepseek-ai/dsh-client-connection" - ], - "platform": "web" - } - } -} diff --git a/packages/api/activity-controller/src/client/index.ts b/packages/api/activity-controller/src/client/index.ts deleted file mode 100644 index 7338ae3b9b..0000000000 --- a/packages/api/activity-controller/src/client/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** Activity-specific adapter for the Gateway-owned snapshot stream lifecycle. */ - -import type { Context } from '@deepseek-ai/cordis' -import { - RemoteSnapshotStream, - RemoteStreamCarrierError, - type ClientRemote, -} from '@deepseek-ai/dsh-api-gateway/client' -import type { ActivityControlFrame } from '../types.ts' -import { ClientActivityModel } from './model.ts' -import { ClientActivityFeed } from './service.ts' -import type { ActivityRemote } from './service.ts' - -export type { ActivityFeedPhase, ActivityFeedSnapshot, ObservedActivity } from './model.ts' -export type { ActivityFeedSource, ActivityRemote, IActivityFeed } from './service.ts' -export type { ActivityId } from '@deepseek-ai/dsh-activity/brand' -export type { ActivityRow } from '../types.ts' - -type ActivityStreamRemote = Pick & { readonly activity: ActivityRemote } - -type ActivityBaselineFrame = Extract -type ActivityRowsFrame = Extract - -declare module '@deepseek-ai/cordis' { - interface Context { - /** React-free client activity roster and observation control. */ - activityFeed: import('./service.ts').IActivityFeed - } -} - -/** Required Client Remote services. */ -export const inject = ['remote', 'remote.activity'] - -/** - * Install the client activity model, its service face, and the reconnecting - * roster stream. - * @param ctx - client root Context. - */ -export function apply(ctx: Context): void { - const raw = ctx.remote as ActivityStreamRemote - // Resolve the child namespace to a concrete value while this plugin's - // context is current: later stream (re)opens run on caller stacks (a React - // event, a carrier retry) whose dynamic context has not declared - // `remote.activity`, and a property access there fails the inject check. - const remote: ActivityStreamRemote = { - $stream: options => raw.$stream(options), - activity: raw.activity, - } - const model = new ClientActivityModel() - new ClientActivityFeed(ctx, remote, model) - const control = createActivityRosterStream(remote, model) - control.start() - ctx.effect( - () => async () => { await control.dispose() }, - 'activity-controller.client.control', - ) -} - -/** - * Create the reconnecting roster stream feeding one client model. - * @param remote - generated activity namespace and Gateway stream factory. - * @param model - roster sink. - * @returns an unstarted stream owned by the client activity runtime. - */ -export function createActivityRosterStream( - remote: ActivityStreamRemote, - model: ClientActivityModel, -): RemoteSnapshotStream { - const stream = remote.$stream({ - name: 'activity roster stream', - open: signal => remote.activity.control(signal), - ended: accepted => accepted - ? new RemoteStreamCarrierError('activity roster stream ended without a terminal result') - : new Error('activity roster stream ended before its opening baseline'), - }) - return new RemoteSnapshotStream(stream, { - name: 'activity roster stream', - isSnapshot: (frame): frame is ActivityBaselineFrame => frame.type === 'baseline', - replace: (frame) => { model.replaceBaseline(frame.activities) }, - update: (frame) => { model.replaceBucket(frame.sessionId, frame.activities) }, - failed: () => { - // The previous roster stays published while the stream retries; a - // terminal protocol failure leaves the last snapshot in place. - }, - }) -} diff --git a/packages/api/activity-controller/src/client/model.ts b/packages/api/activity-controller/src/client/model.ts deleted file mode 100644 index 75354f1255..0000000000 --- a/packages/api/activity-controller/src/client/model.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** Client-side activity state model shared by Remote transport and UI projection. */ - -import { notifySubscribers } from '@deepseek-ai/dsh-client-store' -import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' -import type { ActivityObserveFrame, ActivityRow } from '../types.ts' - -/** Bounded per-activity render tail, in UTF-16 code units. */ -const RENDER_TAIL_LIMIT = 128 * 1024 - -/** Roster arrival lifecycle: `pending` until the first baseline lands. */ -export type ActivityFeedPhase = 'pending' | 'ready' - -/** One observed activity's live view state. */ -export interface ObservedActivity { - readonly activityId: ActivityId - /** Accumulated output tail, bounded to the render limit. */ - readonly text: string - /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ - readonly gapBefore: boolean - /** Latest status delivered on the observation stream. */ - readonly status: ActivityRow['status'] - readonly detail?: string - /** True while the observation stream is open and the activity has not settled. */ - readonly streaming: boolean - /** Terminal observation failure, when the stream ended abnormally. */ - readonly error?: string -} - -/** Immutable client activity state. */ -export interface ActivityFeedSnapshot { - /** Roster rows per owner session; the empty key holds unowned activities. */ - readonly rowsBySession: Readonly> - /** Live observation state keyed by activity id. */ - readonly observed: Readonly> - readonly phase: ActivityFeedPhase -} - -const UNOWNED = '' - -/** Mutable observation bookkeeping behind one {@link ObservedActivity} view. */ -interface ObservedState { - view: ObservedActivity - /** Resume offset for the next generation's `from`. */ - cursor: number | undefined -} - -/** - * Owns the client roster mirror and per-activity observation state. Pure data - * plus subscriptions — transport wiring stays in the client plugin, UI stays - * in slot components. - */ -export class ClientActivityModel { - private rowsBySession: Readonly> = {} - private readonly observedStates = new Map() - private phase: ActivityFeedPhase = 'pending' - private readonly listeners = new Set<() => void>() - private snapshotCache: ActivityFeedSnapshot = { rowsBySession: {}, observed: {}, phase: 'pending' } - private snapshotDirty = false - - /** - * Read the identity-stable current snapshot. - * @returns the cached snapshot, rebuilt only after a change. - */ - getSnapshot(): ActivityFeedSnapshot { - if (this.snapshotDirty) { - const observed: Record = {} - for (const [id, state] of this.observedStates) observed[id] = state.view - this.snapshotCache = { rowsBySession: this.rowsBySession, observed, phase: this.phase } - this.snapshotDirty = false - } - return this.snapshotCache - } - - /** - * Subscribe to snapshot changes. - * @param listener - invalidation callback. - * @returns unsubscribe function. - */ - subscribe(listener: () => void): () => void { - this.listeners.add(listener) - return () => { this.listeners.delete(listener) } - } - - /** - * Replace the whole roster from a generation baseline. - * @param rows - every visible row across all owner buckets. - */ - replaceBaseline(rows: readonly ActivityRow[]): void { - const next: Record = {} - for (const row of rows) { - const key = row.sessionId ?? UNOWNED - ;(next[key] ??= []).push(row) - } - this.rowsBySession = next - this.phase = 'ready' - this.changed() - } - - /** - * Replace one owner bucket wholesale; an empty replacement drops the key. - * @param sessionId - owner session, or undefined for the unowned bucket. - * @param rows - the bucket's complete visible rows. - */ - replaceBucket(sessionId: string | undefined, rows: readonly ActivityRow[]): void { - const key = sessionId ?? UNOWNED - if (rows.length === 0) { - this.rowsBySession = Object.fromEntries( - Object.entries(this.rowsBySession).filter(([bucket]) => bucket !== key)) - } else { - this.rowsBySession = { ...this.rowsBySession, [key]: rows } - } - this.changed() - } - - /** - * The resume offset for one activity's next observation generation. - * @param id - observed activity. - * @returns the last accepted `next`, or undefined for a fresh observation. - */ - cursorOf(id: ActivityId): number | undefined { - return this.observedStates.get(String(id))?.cursor - } - - /** - * Install or reset observation state when a generation's anchor arrives. - * @param id - observed activity. - * @param frame - the generation's `opened` anchor. - */ - observeOpened(id: ActivityId, frame: Extract): void { - const existing = this.observedStates.get(String(id)) - // A fresh view anchored past offset zero starts after an evicted head - // (fresh observations anchor at the registry's earliest retained byte), so - // it owes the same gap mark a live observer earned from lossy reads. A - // resume that already accumulated text keeps its recorded gap state. - const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 - const view: ObservedActivity = { - activityId: id, - text: existing?.view.text ?? '', - gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, - status: frame.status, - ...frame.detail !== undefined ? { detail: frame.detail } : {}, - streaming: true, - } - this.observedStates.set(String(id), { view, cursor: frame.from }) - this.changed() - } - - /** - * Append one output frame's chunks to the bounded render tail. - * @param id - observed activity. - * @param frame - a coalesced `output` frame. - */ - observeOutput(id: ActivityId, frame: Extract): void { - const state = this.observedStates.get(String(id)) - /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ - if (state === undefined) return - let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') - let gapBefore = state.view.gapBefore || frame.lossy === true - || frame.chunks.some(chunk => chunk.gapBefore === true) - if (text.length > RENDER_TAIL_LIMIT) { - let cut = text.length - RENDER_TAIL_LIMIT - // Never split a surrogate pair at the render bound. - const unit = text.charCodeAt(cut) - if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 - text = text.slice(cut) - gapBefore = true - } - state.view = { ...state.view, text, gapBefore } - state.cursor = frame.next - this.changed() - } - - /** - * Record the terminal status delivered at the end of the observation stream. - * @param id - observed activity. - * @param frame - the terminal `status` frame. - */ - observeStatus(id: ActivityId, frame: Extract): void { - const state = this.observedStates.get(String(id)) - /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ - if (state === undefined) return - state.view = { - ...state.view, - status: frame.status, - ...frame.detail !== undefined ? { detail: frame.detail } : {}, - streaming: false, - } - this.changed() - } - - /** - * Record a terminal observation failure. - * @param id - observed activity. - * @param error - the stream's terminal failure. - */ - observeFailed(id: ActivityId, error: unknown): void { - const state = this.observedStates.get(String(id)) - if (state === undefined) return - state.view = { ...state.view, streaming: false, error: String(error) } - this.changed() - } - - /** - * Drop observation state after the last observer stops. - * @param id - the no-longer-observed activity. - */ - observeStopped(id: ActivityId): void { - if (!this.observedStates.delete(String(id))) return - this.changed() - } - - private changed(): void { - this.snapshotDirty = true - notifySubscribers(this.listeners, 'activity feed') - } -} diff --git a/packages/api/activity-controller/src/client/service.ts b/packages/api/activity-controller/src/client/service.ts deleted file mode 100644 index 2947e5bf04..0000000000 --- a/packages/api/activity-controller/src/client/service.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** React-free client activity service: roster snapshot plus observation control. */ - -import { Service, type Context } from '@deepseek-ai/cordis' -import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' -import type {} from '@deepseek-ai/dsh-api-activity-controller/remote' -import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' -import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' -import type { ActivityObserveFrame } from '../types.ts' -import type { ActivityFeedSnapshot, ClientActivityModel } from './model.ts' - -/** Complete generated `ctx.remote.activity` namespace. */ -export type ActivityRemote = TypertClientRemote['activity'] - -/** Bare observable source for the client activity snapshot. */ -export interface ActivityFeedSource { - /** Read the identity-stable current snapshot. */ - getSnapshot(): ActivityFeedSnapshot - /** - * Subscribe to snapshot changes. - * @param listener - invalidation callback. - * @returns unsubscribe function. - */ - subscribe(listener: () => void): () => void -} - -/** The activity feed's client service face. */ -export interface IActivityFeed { - /** Roster rows, observation state, and arrival phase. */ - readonly state: ActivityFeedSource - /** - * Start observing one activity's live output; reference-counted, so two - * viewers of the same activity share one stream. - * @param id - activity to observe. - * @returns stop function releasing this observer's reference. - */ - observe(id: ActivityId): () => void -} - -/** One reference-counted observation stream. */ -interface ObservationEntry { - refs: number - stopped: boolean - dispose: () => Promise -} - -/** Remote face the observation runner drives. */ -type ActivityStreamRemote = Pick & { readonly activity: ActivityRemote } - -/** Owns the bare activity snapshot and per-activity observation streams. */ -export class ClientActivityFeed extends Service implements IActivityFeed { - readonly state: ActivityFeedSource - private readonly entries = new Map() - - /** - * @param ctx - client root Context. - * @param remote - generated activity namespace plus the Gateway stream factory. - * @param model - shared client activity model. - */ - constructor( - ctx: Context, - private readonly remote: ActivityStreamRemote, - private readonly model: ClientActivityModel, - ) { - super(ctx, 'activityFeed') - this.state = model - ctx.effect(() => () => { - const open = [...this.entries.values()] - this.entries.clear() - for (const entry of open) { - entry.stopped = true - void entry.dispose() - } - }, 'activity-controller.client.observations') - } - - observe(id: ActivityId): () => void { - const key = String(id) - const existing = this.entries.get(key) - if (existing !== undefined && !existing.stopped) { - existing.refs += 1 - return this.releaser(key, existing) - } - const entry = this.startObservation(id) - this.entries.set(key, entry) - return this.releaser(key, entry) - } - - /** - * Release closures bind the exact entry they were minted for, never the - * map's current occupant: a later `observe()` on the same id may have - * replaced a stopped entry, and decrementing or disposing through the key - * alone would tear down that newer stream's references. - */ - private releaser(key: string, entry: ObservationEntry): () => void { - let released = false - return () => { - if (released) return - released = true - entry.refs -= 1 - if (entry.refs > 0) return - if (this.entries.get(key) === entry) this.entries.delete(key) - entry.stopped = true - void entry.dispose().then(() => { - // Clear the view only while no successor observation holds the key: - // a re-expand inside the dispose round-trip already re-anchored the - // model, and a stale clear would blank its panel for good. - if (this.entries.has(key)) return - // The key is the stringified branded id this releaser was minted for. - this.model.observeStopped(key as ActivityId) - }) - } - } - - private startObservation(id: ActivityId): ObservationEntry { - const name = `activity observation ${String(id)}` - const stream = this.remote.$stream({ - name, - open: (signal) => { - const from = this.model.cursorOf(id) - return this.remote.activity.observe( - { activityId: id, ...from !== undefined ? { from } : {} }, - signal, - ) - }, - // A premature end after the anchor is retryable (a Host reload closes the - // generation); resuming from the cursor loses nothing. An end before the - // anchor is terminal. - ended: accepted => accepted - ? new RemoteStreamCarrierError(`${name} ended before settlement`) - : new Error(`${name} ended before its anchor`), - }) - const entry: ObservationEntry = { - refs: 1, - stopped: false, - dispose: () => stream.dispose(), - } - void (async () => { - try { - for await (const item of stream) { - const frame = item.value - if (frame.type === 'opened') { - this.model.observeOpened(id, frame) - item.accept() - continue - } - if (frame.type === 'output') { - this.model.observeOutput(id, frame) - continue - } - // Terminal status: leave the loop before the generation end is - // classified, then close the stream for good. - this.model.observeStatus(id, frame) - break - } - } catch (error) { - if (!entry.stopped) this.model.observeFailed(id, error) - } finally { - entry.stopped = true - void entry.dispose() - } - })() - return entry - } -} diff --git a/packages/api/activity-controller/src/feed.ts b/packages/api/activity-controller/src/feed.ts deleted file mode 100644 index 34091dad8d..0000000000 --- a/packages/api/activity-controller/src/feed.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** Reconnect-safe activity roster producer over the optional `ctx.activities` registry. */ - -import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ActivityRegistry, ActivitySnapshot } from '@deepseek-ai/dsh-activity' -import type { ActivityControlFrame, ActivityRow } from './types.ts' - -/** - * Project one registry snapshot into its browser-safe roster row. - * @param snapshot - fresh registry snapshot. - * @returns detached wire row for Remote consumers. - */ -export function activityRow(snapshot: ActivitySnapshot): ActivityRow { - return { - id: snapshot.id, - kind: snapshot.kind, - label: snapshot.label, - ...snapshot.ownerSession !== undefined ? { sessionId: snapshot.ownerSession } : {}, - ...snapshot.correlation !== undefined - ? { - correlation: { - ...snapshot.correlation.callId !== undefined ? { callId: snapshot.correlation.callId } : {}, - ...snapshot.correlation.jobId !== undefined ? { jobId: snapshot.correlation.jobId } : {}, - }, - } - : {}, - status: snapshot.status, - ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, - startedAt: snapshot.startedAt, - ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, - outputTotal: snapshot.outputTotal, - } -} - -/** One owner bucket: the exact live owner used for fenced reads plus its rows. */ -interface OwnerBucket { - /** Exact registered Agent; undefined for the unowned bucket. */ - readonly owner: Agent | undefined - rows: readonly ActivityRow[] -} - -const UNOWNED = '' - -/** - * Owns registry observation, the owner-bucketed roster mirror, and all active - * control-stream generations. The mirror also answers `ownerOf`, which - * observation streams use to read owned activities through the visibility - * fence with the exact owner the registry announced. - */ -export class ActivityFeed { - private readonly followers = new Set() - private readonly buckets = new Map() - private readonly registry: ActivityRegistry | undefined - - /** @param ctx - Host context; the activity registry stays optional. */ - constructor(ctx: Context) { - this.registry = ctx.get('activities') - if (this.registry === undefined) return - const registry = this.registry - this.refresh(undefined) - for (const agent of ctx.get('agents')?.list() ?? []) this.refresh(agent) - registry.onActivitiesChanged((owner) => { - this.refresh(owner) - const bucket = this.buckets.get(owner?.id ?? UNOWNED) - this.publish({ - type: 'rows', - ...owner !== undefined ? { sessionId: owner.id } : {}, - activities: bucket?.rows ?? [], - }) - }) - ctx.effect(() => () => { - for (const follower of this.followers) follower.close() - this.followers.clear() - }, 'activity-controller.feed') - } - - /** - * Read the complete current roster synchronously. - * @returns every visible row across all owner buckets. - */ - baseline(): readonly ActivityRow[] { - return [...this.buckets.values()].flatMap(bucket => bucket.rows) - } - - /** - * Resolve the exact owner the registry announced for one activity, for - * fenced reads. `undefined` covers both unowned activities and ids this - * mirror has never seen — the registry itself settles unknown ids. - * @param id - activity id to resolve. - * @returns the exact live owner, or undefined. - */ - ownerOf(id: string): Agent | undefined { - for (const bucket of this.buckets.values()) { - if (bucket.rows.some(row => String(row.id) === id)) return bucket.owner - } - return undefined - } - - /** - * Open one generation beginning with a complete baseline. - * @param signal - generation cancellation. - * @returns baseline followed by whole-bucket replacement frames. - */ - async *follow(signal: AbortSignal): AsyncIterable { - signal.throwIfAborted() - const follower = new ActivityFollower() - this.followers.add(follower) - try { - yield { type: 'baseline', activities: this.baseline() } - yield* follower.read(signal) - } finally { - this.followers.delete(follower) - follower.close() - } - } - - /** Re-read one owner's visible rows into its bucket; an emptied bucket is dropped. */ - private refresh(owner: Agent | undefined): void { - /* v8 ignore next -- refresh is only reachable through a live registry's change feed. */ - if (this.registry === undefined) return - const rows = this.registry.list(owner) - .filter(snapshot => owner === undefined - ? snapshot.ownerSession === undefined - : snapshot.ownerSession === owner.id) - .map(activityRow) - const key = owner?.id ?? UNOWNED - if (rows.length === 0) { - this.buckets.delete(key) - return - } - this.buckets.set(key, { owner, rows }) - } - - private publish(frame: Exclude): void { - for (const follower of this.followers) follower.push(frame) - } -} - -/* jscpd:ignore-start -- mirrors WorkspaceFeed's follower fan-out by design; both serve baseline-plus-increment Remote streams. */ -class ActivityFollower { - private readonly frames: ActivityControlFrame[] = [] - private waiting: (() => void) | undefined - private closed = false - - push(frame: ActivityControlFrame): void { - /* v8 ignore next -- closed followers are removed before later publication can reach them. */ - if (this.closed) return - this.frames.push(frame) - this.waiting?.() - } - - close(): void { - if (this.closed) return - this.closed = true - this.waiting?.() - } - - async *read(signal: AbortSignal): AsyncIterable { - while (!this.closed && !signal.aborted) { - const frame = this.frames.shift() - if (frame !== undefined) { - yield frame - continue - } - await this.wait(signal) - } - } - - private wait(signal: AbortSignal): Promise { - return new Promise((resolve) => { - const finish = (): void => { - signal.removeEventListener('abort', finish) - /* v8 ignore next -- one read owns the sole installed wait callback. */ - if (this.waiting === finish) this.waiting = undefined - resolve() - } - this.waiting = finish - signal.addEventListener('abort', finish, { once: true }) - /* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */ - if (signal.aborted || this.closed || this.frames.length > 0) finish() - }) - } -} -/* jscpd:ignore-end */ diff --git a/packages/api/activity-controller/src/index.ts b/packages/api/activity-controller/src/index.ts deleted file mode 100644 index a2d62c4a33..0000000000 --- a/packages/api/activity-controller/src/index.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Host Activity Remote owner: the live-activity roster stream and per-activity - * output observation. A pure read surface over the optional `ctx.activities` - * registry — it starts nothing, cancels nothing, and never touches the - * model-facing consuming cursors. Compositions without the registry serve an - * empty roster and refuse observation with a stable error. - */ - -import { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -import type {} from '@deepseek-ai/dsh-activity' -import { ActivityFeed } from './feed.ts' -import { observeActivity } from './observe.ts' -import type { ActivityControlFrame, ActivityObserveFrame, ActivityObserveRequest } from './types.ts' - -export type * from './types.ts' - -/** Default coalescing window between observation reads, in milliseconds. */ -const DEFAULT_FLUSH_MS = 100 - -/** Default soft byte budget per observation output frame. */ -const DEFAULT_MAX_FRAME_BYTES = 64 * 1024 - -/** Configuration for the Activity Remote owner. */ -export interface Config { - /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ - flushMs?: number - /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ - maxFrameBytes?: number -} - -declare module '@deepseek-ai/cordis' { - interface Context { - /** Host Activity read API and Remote namespace owner. */ - activityController: ActivityController - } -} - -/** Host service backing the generated `ctx.remote.activity` namespace. */ -export class ActivityController extends TypertRemoteService { - static inject = ['typert'] - - static Config: z = z.object({ - flushMs: z.number() - .step(1) - .min(1) - .max(Number.MAX_SAFE_INTEGER) - .default(DEFAULT_FLUSH_MS), - maxFrameBytes: z.number() - .step(1) - .min(1) - .max(Number.MAX_SAFE_INTEGER) - .default(DEFAULT_MAX_FRAME_BYTES), - }) - - private readonly feed: ActivityFeed - private readonly flushMs: number - private readonly maxFrameBytes: number - - /** - * @param ctx - Host context; the activity registry stays optional. - * @param config - validated coalescing and framing bounds. - */ - constructor(ctx: Context, config: Config) { - super(ctx, 'activityController', { namespace: 'activity' }) - this.flushMs = (config as Required).flushMs - this.maxFrameBytes = (config as Required).maxFrameBytes - this.feed = new ActivityFeed(ctx) - } - - /** - * Stream a complete roster baseline followed by whole-bucket replacements. - * @param signal - generation cancellation. - * @returns baseline followed by per-owner roster replacement frames. - */ - @Remote({ mode: 'stream' }) - control(signal: AbortSignal): AsyncIterable { - return this.feed.follow(signal) - } - - /** - * Stream one activity's retained output from an absolute byte offset, then - * its terminal status once settled and drained. Non-consuming: the - * model-facing cursors never observe these reads. - * @param request - target activity and optional resume offset. - * @param signal - generation cancellation. - * @returns anchor, coalesced output frames, and the terminal status. - */ - @Remote({ mode: 'stream' }) - observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable { - const registry = this.ctx.get('activities') - if (registry === undefined) { - throw new Error('activity observation unavailable: no activity registry is loaded (load @deepseek-ai/dsh-activity-local)') - } - return observeActivity(registry, request, { - flushMs: this.flushMs, - maxFrameBytes: this.maxFrameBytes, - ownerOf: id => this.feed.ownerOf(id), - }, signal) - } -} - -export default ActivityController diff --git a/packages/api/activity-controller/src/invariant.ts b/packages/api/activity-controller/src/invariant.ts deleted file mode 100644 index f85e7460b8..0000000000 --- a/packages/api/activity-controller/src/invariant.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-activity-controller/invariant */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-api-activity-controller' - -/** Cordis companion plugin name. */ -export const name = 'api-activity-controller-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: `@deepseek-ai/dsh-activity/invariant` owns snapshot relations; every roster - * generation is a full projection and observation reads are validated by the registry itself. - */ -const install: InvariantInstaller = () => {} - -/** Register this package's invariant companion. */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/api/activity-controller/src/observe.ts b/packages/api/activity-controller/src/observe.ts deleted file mode 100644 index f793b11496..0000000000 --- a/packages/api/activity-controller/src/observe.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** Per-activity observation generations: anchor, coalesced output, terminal status. */ - -import type { ActivityRegistry, ActivitySnapshot } from '@deepseek-ai/dsh-activity' -import { ActivityId } from '@deepseek-ai/dsh-activity/brand' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ActivityObserveFrame, ActivityObserveRequest, ActivityWireChunk } from './types.ts' - -/** Cadence and framing bounds for one observation generation. */ -export interface ObserveOptions { - /** Coalescing window after a wake before reading, in milliseconds. */ - readonly flushMs: number - /** - * Soft byte budget per `output` frame. Accumulated chunks flush once the - * budget is met; one chunk larger than the budget ships whole (the - * registry's retention cap is the hard bound on any single chunk). - */ - readonly maxFrameBytes: number - /** Resolve the exact owner for fenced reads (the roster mirror's `ownerOf`). */ - readonly ownerOf: (id: string) => Agent | undefined -} - -function isTerminal(status: ActivitySnapshot['status']): boolean { - return status !== 'running' -} - -/** Wake-flag waiter: a wake between waits is never lost. */ -class OutputWaiter { - private dirty = false - private resolve: (() => void) | undefined - - wake(): void { - this.dirty = true - this.resolve?.() - } - - /** - * Resolve on the next wake, immediately when one already arrived, or on abort. - * @param signal - generation cancellation. - */ - wait(signal: AbortSignal): Promise { - if (this.dirty || signal.aborted) { - this.dirty = false - return Promise.resolve() - } - return new Promise((resolve) => { - const finish = (): void => { - signal.removeEventListener('abort', finish) - /* v8 ignore next -- one wait owns the sole installed resolver. */ - if (this.resolve === finish) this.resolve = undefined - this.dirty = false - resolve() - } - this.resolve = finish - signal.addEventListener('abort', finish, { once: true }) - }) - } -} - -function sleep(ms: number, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve() - return new Promise((resolve) => { - const timer = setTimeout(done, ms) - function done(): void { - clearTimeout(timer) - signal.removeEventListener('abort', done) - resolve() - } - signal.addEventListener('abort', done, { once: true }) - }) -} - -/** - * Stream one activity's retained output from an absolute offset: one `opened` - * anchor, coalesced `output` frames as the stream advances, then one terminal - * `status` after the settled activity is drained, after which the generation - * closes normally. Reads are non-consuming; reconnecting callers resume by - * passing the last frame's `next` as `from`. - * @param registry - the live activity registry. - * @param request - target activity and optional resume offset. - * @param options - cadence, framing bounds, and owner resolution. - * @param signal - generation cancellation owned by the Remote stream carrier. - * @returns the observation frame sequence for one generation. - */ -export async function* observeActivity( - registry: ActivityRegistry, - request: ActivityObserveRequest, - options: ObserveOptions, - signal: AbortSignal, -): AsyncIterable { - if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { - throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) - } - signal.throwIfAborted() - const id = ActivityId(String(request.activityId)) - const waiter = new OutputWaiter() - // Subscribe before the first read so an append between the anchor read and - // the wait cannot be missed. - const unsubscribe = registry.onOutput((changed) => { - if (changed === id) waiter.wake() - }) - try { - const caller = options.ownerOf(String(id)) - let snapshot = registry.get(id, caller) - let cursor = request.from ?? snapshot.outputEarliest - yield { - type: 'opened', - activityId: id, - from: cursor, - earliest: snapshot.outputEarliest, - total: snapshot.outputTotal, - status: snapshot.status, - ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, - } - while (!signal.aborted) { - const read = registry.read(id, cursor, caller) - if (read.chunks.length > 0) { - yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes) - } - cursor = read.next - snapshot = registry.get(id, caller) - if (isTerminal(snapshot.status) && cursor >= snapshot.outputTotal) { - yield { - type: 'status', - status: snapshot.status, - ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, - // Settlement always stamps finishedAt; the guard only discharges the optional field type. - /* v8 ignore next */ - ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, - } - return - } - await waiter.wait(signal) - // Let a burst accumulate so producer chatter becomes bounded frames. - await sleep(options.flushMs, signal) - } - } finally { - unsubscribe() - } -} - -/** Split one read into frames along the soft per-frame byte budget. */ -function* outputFrames( - chunks: readonly ActivityWireChunk[], - next: number, - lossy: boolean, - maxFrameBytes: number, -): Iterable { - let batch: ActivityWireChunk[] = [] - let batchBytes = 0 - let flaggedLossy = lossy - for (const chunk of chunks) { - batch.push(chunk) - batchBytes += Buffer.byteLength(chunk.text, 'utf8') - if (batchBytes >= maxFrameBytes) { - const last = batch[batch.length - 1] - /* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */ - const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, 'utf8') - /* v8 ignore stop */ - yield { - type: 'output', - chunks: batch, - next: end, - ...flaggedLossy ? { lossy: true as const } : {}, - } - flaggedLossy = false - batch = [] - batchBytes = 0 - } - } - if (batch.length > 0 || flaggedLossy) { - yield { - type: 'output', - chunks: batch, - next, - ...flaggedLossy ? { lossy: true as const } : {}, - } - } -} diff --git a/packages/api/activity-controller/src/types.ts b/packages/api/activity-controller/src/types.ts deleted file mode 100644 index bc2e39808d..0000000000 --- a/packages/api/activity-controller/src/types.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** Browser-safe roster and observation vocabulary for the Activity Remote. */ - -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' - -export type { ActivityId } from '@deepseek-ai/dsh-activity/brand' - -/** Lifecycle state carried on the wire; mirrors the registry's status union. */ -export type ActivityRowStatus = 'running' | 'completed' | 'failed' | 'killed' - -/** Links one roster row to the tool call and background job for the same work. */ -export interface ActivityRowCorrelation { - /** Tool call that started the work, when a tool produced it. */ - readonly callId?: ToolCallId - /** Background job carrying the model-facing control surface, when one exists. */ - readonly jobId?: JobId -} - -/** - * Browser-safe activity row. `kind` is `string` on the wire because the kind - * map is merge-extensible by producer plugins; presentation falls through a - * documented default for an unrecognized kind. - */ -export interface ActivityRow { - readonly id: ActivityId - readonly kind: string - readonly label: string - /** Owning session for grouping; absent for an unowned activity, visible to every session. */ - readonly sessionId?: SessionId - readonly correlation?: ActivityRowCorrelation - readonly status: ActivityRowStatus - readonly detail?: string - readonly startedAt: number - readonly finishedAt?: number - /** Total UTF-8 bytes the activity has produced so far. */ - readonly outputTotal: number -} - -/** - * Roster stream frames. Each generation starts with exactly one complete - * baseline; later frames replace one owner bucket wholesale (absent - * `sessionId` = the unowned bucket), so start, settlement, removal, and a - * second browser tab all converge through one authoritative value. - */ -export type ActivityControlFrame = - | { readonly type: 'baseline'; readonly activities: readonly ActivityRow[] } - | { readonly type: 'rows'; readonly sessionId?: SessionId; readonly activities: readonly ActivityRow[] } - -/** Opens one observation stream over a single activity's retained output. */ -export interface ActivityObserveRequest { - readonly activityId: ActivityId - /** - * Absolute byte offset to resume from — a previous frame's `next`. Omitted - * starts at the oldest retained byte. - */ - readonly from?: number -} - -/** One retained output chunk on the wire. `channel` widens to string like `kind`. */ -export interface ActivityWireChunk { - /** Absolute offset of the chunk's first byte. */ - readonly at: number - readonly text: string - readonly channel?: string - /** Bytes immediately before this chunk were lost at the producer or to retention. */ - readonly gapBefore?: true -} - -/** - * Observation stream frames: one `opened` anchor, then coalesced `output` - * batches, then — once the activity has settled and its retained output is - * drained — one terminal `status`, after which the stream closes normally. - * Status rides the same stream as output so settlement can never race a - * still-open output channel. - */ -export type ActivityObserveFrame = - | { - readonly type: 'opened' - readonly activityId: ActivityId - /** Offset the first `output` frame continues from. */ - readonly from: number - /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ - readonly earliest: number - /** Total bytes produced at open time. */ - readonly total: number - readonly status: ActivityRowStatus - readonly detail?: string - } - | { - readonly type: 'output' - readonly chunks: readonly ActivityWireChunk[] - /** Offset to resume from after this frame. */ - readonly next: number - /** Bytes between the requested offset and `chunks` were already evicted. */ - readonly lossy?: true - } - | { - readonly type: 'status' - readonly status: ActivityRowStatus - readonly detail?: string - readonly finishedAt?: number - } diff --git a/packages/api/activity-controller/tests/activity-controller.host.spec.ts b/packages/api/activity-controller/tests/activity-controller.host.spec.ts deleted file mode 100644 index 757a0af115..0000000000 --- a/packages/api/activity-controller/tests/activity-controller.host.spec.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { Session, SessionId } from '@deepseek-ai/dsh-session' -import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' -import { ActivityId } from '@deepseek-ai/dsh-activity' -import { ActivityFeed } from '../src/feed.ts' -import { observeActivity } from '../src/observe.ts' -import type { ObserveOptions } from '../src/observe.ts' -import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts' - -async function harness(withRegistry = true) { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - if (withRegistry) await ctx.plugin(LocalActivityRegistry, { retainBytes: 64, settledRetainBytes: 64 }) - return ctx -} - -function registerAgent(ctx: Context, rawId: string): Agent { - const scopeFiber = ctx.plugin(() => {}) - const id = SessionId(rawId) - const session = Session.create(id) - const agent = { - id, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle', - ctx: scopeFiber.ctx, - } as unknown as Agent - ctx.agents.register(agent) - return agent -} - -/** Read frames until `count` arrive or the generator finishes, then abort. */ -async function collect( - iterable: AsyncIterable, - count: number, - abort: AbortController, -): Promise { - const frames: Frame[] = [] - const iterator = iterable[Symbol.asyncIterator]() - try { - while (frames.length < count) { - const step = await iterator.next() - if (step.done) return frames - frames.push(step.value) - } - } finally { - abort.abort() - await iterator.next().catch(() => undefined) - } - return frames -} - -const OBSERVE: Omit = { flushMs: 5, maxFrameBytes: 1024 } - -describe('ActivityFeed roster', () => { - it('serves an empty baseline without the registry', async () => { - const ctx = await harness(false) - const feed = new ActivityFeed(ctx) - expect(feed.baseline()).toEqual([]) - }) - - it('projects owned and unowned rows into the baseline with correlation and no live objects', async () => { - const ctx = await harness() - const owner = registerAgent(ctx, 'alice') - ctx.activities.open({ kind: 'bash', label: 'unowned build' }) - ctx.activities.open({ - kind: 'bash', - label: 'owned build', - owner, - correlation: { jobId: 'bash-7' as never }, - }) - const feed = new ActivityFeed(ctx) - const rows = feed.baseline() - expect(rows).toHaveLength(2) - const owned = rows.find(row => row.sessionId !== undefined) - expect(owned?.sessionId).toBe('alice') - expect(owned?.correlation).toEqual({ jobId: 'bash-7' }) - expect(owned?.status).toBe('running') - for (const row of rows) { - expect(Object.keys(row)).not.toContain('owner') - expect(row.outputTotal).toBe(0) - } - }) - - it('pushes whole-bucket frames for open, detail, settlement, and removal', async () => { - const ctx = await harness() - const owner = registerAgent(ctx, 'alice') - const feed = new ActivityFeed(ctx) - const abort = new AbortController() - const frames = collect(feed.follow(abort.signal), 4, abort) - await new Promise(resolve => setTimeout(resolve, 0)) - const handle = ctx.activities.open({ kind: 'bash', label: 'work', owner }) - handle.updateDetail('halfway') - handle.end({ status: 'completed', detail: 'exit code: 0' }) - const seen = await frames - expect(seen[0]).toEqual({ type: 'baseline', activities: [] }) - const rows = seen.slice(1) as Extract[] - expect(rows.every(frame => frame.sessionId === 'alice')).toBe(true) - expect(rows[0]?.activities[0]?.status).toBe('running') - expect(rows[1]?.activities[0]?.detail).toBe('halfway') - expect(rows[2]?.activities[0]?.status).toBe('completed') - expect(feed.ownerOf(String(rows[0]!.activities[0]!.id))).toBe(owner) - }) - - it('projects callId-only correlation and serves rosters without an agent registry', async () => { - const bare = new Context() - await bare.plugin(LocalActivityRegistry) - bare.activities.open({ kind: 'bash', label: 'lone', correlation: { callId: 'call-1' as never } }) - const feed = new ActivityFeed(bare) - expect(feed.baseline()[0]?.correlation).toEqual({ callId: 'call-1' }) - }) - - it('pushes an empty bucket frame when owner disposal removes the last row', async () => { - const ctx = await harness() - const owner = registerAgent(ctx, 'alice') - const feed = new ActivityFeed(ctx) - ctx.activities.open({ kind: 'bash', label: 'doomed', owner }) - const abort = new AbortController() - const frames = collect(feed.follow(abort.signal), 3, abort) - await new Promise(resolve => setTimeout(resolve, 0)) - await (owner.ctx as unknown as { fiber: { dispose(): Promise } }).fiber?.dispose?.() - // Owner-disposal removal reaches the feed as an emptied bucket. - await new Promise(resolve => setTimeout(resolve, 10)) - abort.abort() - const seen = await frames - const last = seen.at(-1) as Extract | undefined - if (last !== undefined && last.type === 'rows') expect(Array.isArray(last.activities)).toBe(true) - }) - - it('resolves undefined for unowned and unknown activities', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const handle = ctx.activities.open({ kind: 'bash', label: 'shared' }) - expect(feed.ownerOf(String(handle.id))).toBeUndefined() - expect(feed.ownerOf('bash-999')).toBeUndefined() - }) -}) - -describe('observeActivity', () => { - async function observed(ctx: Context, feed: ActivityFeed, request: { activityId: ActivityId; from?: number }) { - const abort = new AbortController() - const frames: ActivityObserveFrame[] = [] - const stream = observeActivity( - ctx.activities, - request, - { ...OBSERVE, ownerOf: id => feed.ownerOf(id) }, - abort.signal, - ) - const done = (async () => { - for await (const frame of stream) frames.push(frame) - })() - return { abort, frames, done } - } - - it('anchors, streams coalesced output, and closes with the terminal status', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('early ') - const { frames, done } = await observed(ctx, feed, { activityId: handle.id }) - await new Promise(resolve => setTimeout(resolve, 20)) - handle.append('live', { channel: 'stderr' }) - await new Promise(resolve => setTimeout(resolve, 20)) - handle.end({ status: 'completed', detail: 'exit code: 0' }) - await done - - expect(frames[0]).toMatchObject({ type: 'opened', from: 0, earliest: 0, total: 6, status: 'running' }) - const outputs = frames.filter(frame => frame.type === 'output') - const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') - expect(text).toBe('early live') - expect(outputs.at(-1)?.next).toBe(10) - expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr') - expect(frames.at(-1)).toMatchObject({ type: 'status', status: 'completed', detail: 'exit code: 0' }) - }) - - it('resumes from an explicit offset and flags an evicted resume lossy', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const handle = ctx.activities.open({ kind: 'bash', label: 'spam' }) - handle.append('a'.repeat(60)) - handle.append('b'.repeat(60)) - handle.end({ status: 'completed' }) - - const fresh = await observed(ctx, feed, { activityId: handle.id, from: 0 }) - await fresh.done - const output = fresh.frames.find(frame => frame.type === 'output') - expect(output?.lossy).toBe(true) - expect(output?.chunks.map(chunk => chunk.text).join('')).toBe('b'.repeat(60)) - - const resumed = await observed(ctx, feed, { activityId: handle.id, from: 120 }) - await resumed.done - expect(resumed.frames.some(frame => frame.type === 'output')).toBe(false) - expect(resumed.frames.at(-1)?.type).toBe('status') - }) - - it('splits oversized reads along the frame budget', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const registry = ctx.activities - const handle = registry.open({ kind: 'bash', label: 'wide' }) - const abort = new AbortController() - handle.append('x'.repeat(30)) - handle.append('y'.repeat(30)) - handle.end({ status: 'completed' }) - const frames = await collect( - observeActivity(registry, { activityId: handle.id }, { - flushMs: 5, - maxFrameBytes: 30, - ownerOf: id => feed.ownerOf(id), - }, abort.signal), - 5, - abort, - ) - const outputs = frames.filter(frame => frame.type === 'output') - expect(outputs).toHaveLength(2) - expect(outputs[0]?.next).toBe(30) - expect(outputs[1]?.next).toBe(60) - }) - - it('carries the lossy flag on the first frame of a split evicted read', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const registry = ctx.activities - const handle = registry.open({ kind: 'bash', label: 'evicted split' }) - handle.append('a'.repeat(40)) - handle.append('b'.repeat(40)) - handle.append('c'.repeat(40)) - handle.end({ status: 'completed' }) - const abort = new AbortController() - const frames = await collect( - observeActivity(registry, { activityId: handle.id, from: 0 }, { - flushMs: 5, - maxFrameBytes: 40, - ownerOf: id => feed.ownerOf(id), - }, abort.signal), - 5, - abort, - ) - const outputs = frames.filter(frame => frame.type === 'output') - expect(outputs[0]?.lossy).toBe(true) - expect(outputs[1]?.lossy).toBeUndefined() - }) - - it('reads owned activities through the roster owner and rejects bad offsets and unknown ids', async () => { - const ctx = await harness() - const owner = registerAgent(ctx, 'alice') - const feed = new ActivityFeed(ctx) - const handle = ctx.activities.open({ kind: 'bash', label: 'secret', owner }) - handle.append('classified') - handle.end({ status: 'completed' }) - const { frames, done } = await observed(ctx, feed, { activityId: handle.id }) - await done - expect(frames.some(frame => frame.type === 'output' - && frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true) - - const abort = new AbortController() - await expect(collect( - observeActivity(ctx.activities, { activityId: handle.id, from: -1 }, { - ...OBSERVE, - ownerOf: id => feed.ownerOf(id), - }, abort.signal), - 1, - abort, - )).rejects.toThrow(/invalid observe offset/) - - const abortUnknown = new AbortController() - await expect(collect( - observeActivity(ctx.activities, { activityId: ActivityId('bash-999') }, { - ...OBSERVE, - ownerOf: id => feed.ownerOf(id), - }, abortUnknown.signal), - 1, - abortUnknown, - )).rejects.toThrow(/unknown activity/) - }) - - it('ignores output signals for other activities while waiting', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const watched = ctx.activities.open({ kind: 'bash', label: 'watched' }) - const noisy = ctx.activities.open({ kind: 'bash', label: 'noisy' }) - const { abort, frames, done } = await observed(ctx, feed, { activityId: watched.id }) - await new Promise(resolve => setTimeout(resolve, 15)) - noisy.append('unrelated') - await new Promise(resolve => setTimeout(resolve, 15)) - watched.end({ status: 'completed' }) - await done - abort.abort() - expect(frames.filter(frame => frame.type === 'output')).toEqual([]) - }) - - it('stops cleanly on abort while waiting for output', async () => { - const ctx = await harness() - const feed = new ActivityFeed(ctx) - const handle = ctx.activities.open({ kind: 'bash', label: 'idle' }) - const { abort, frames, done } = await observed(ctx, feed, { activityId: handle.id }) - await new Promise(resolve => setTimeout(resolve, 20)) - abort.abort() - await done - expect(frames[0]?.type).toBe('opened') - expect(frames.at(-1)?.type).not.toBe('status') - }) -}) diff --git a/packages/api/activity-controller/tests/client-apply.client.spec.ts b/packages/api/activity-controller/tests/client-apply.client.spec.ts deleted file mode 100644 index 231703ba13..0000000000 --- a/packages/api/activity-controller/tests/client-apply.client.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' -import { apply, createActivityRosterStream } from '../src/client/index.ts' -import { ClientActivityModel } from '../src/client/model.ts' -import type { ActivityControlFrame, ActivityId, ActivityRow } from '../src/types.ts' - -function row(): ActivityRow { - return { - id: 'bash-1' as ActivityId, - kind: 'bash', - label: 'build', - status: 'running', - startedAt: 1, - outputTotal: 0, - } -} - -/** One scripted roster stream: frames pushed by the test, never reopened. */ -class FakeStream { - disposed = false - private readonly frames: ActivityControlFrame[] = [] - private waiter: (() => void) | undefined - private failure: Error | undefined - - push(frame: ActivityControlFrame): void { - this.frames.push(frame) - this.waiter?.() - } - - poison(error: Error): void { - this.failure = error - this.waiter?.() - } - - dispose(): Promise { - this.disposed = true - this.waiter?.() - return Promise.resolve() - } - - async *[Symbol.asyncIterator]() { - while (!this.disposed) { - if (this.failure !== undefined) throw this.failure - const frame = this.frames.shift() - if (frame === undefined) { - await new Promise((resolve) => { this.waiter = resolve }) - continue - } - yield { generation: 1, value: frame, signal: new AbortController().signal, accept: () => {} } - } - } -} - -const tick = () => new Promise(resolve => setTimeout(resolve, 0)) - -describe('activity-controller client plugin', () => { - it('installs ctx.activityFeed and folds roster frames from the control stream', async () => { - const ctx = new Context() - const streams: { options: { open: (signal: AbortSignal) => unknown; ended: (accepted: boolean) => Error }; stream: FakeStream }[] = [] - const controlCalls: AbortSignal[] = [] - ctx.provide('remote', { - $stream: (options: never) => { - const stream = new FakeStream() - streams.push({ options, stream }) - return stream - }, - activity: { - control: (signal: AbortSignal) => { - controlCalls.push(signal) - return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } - }, - observe: () => { throw new Error('not exercised') }, - }, - } as never) - const fiber = ctx.plugin({ inject: ['remote'], apply }) - await fiber - - streams[0]!.stream.push({ type: 'baseline', activities: [row()] }) - streams[0]!.stream.push({ type: 'rows', activities: [] }) - await tick() - const snapshot = ctx.activityFeed.state.getSnapshot() - expect(snapshot.phase).toBe('ready') - expect(snapshot.rowsBySession['']).toBeUndefined() - - // The generation opener and end classification belong to the domain. - streams[0]!.options.open(new AbortController().signal) - expect(controlCalls).toHaveLength(1) - expect(streams[0]!.options.ended(true)).toBeInstanceOf(RemoteStreamCarrierError) - expect(streams[0]!.options.ended(false)).toBeInstanceOf(Error) - - // A terminal roster failure keeps the last snapshot in place. - streams[0]!.stream.poison(new Error('roster stream broke')) - await tick() - expect(ctx.activityFeed.state.getSnapshot().phase).toBe('ready') - - await fiber.dispose() - expect(streams[0]!.stream.disposed).toBe(true) - }) - - it('keeps the last roster on a terminal stream failure', async () => { - const model = new ClientActivityModel() - const remote = { - $stream: () => new FakeStream(), - activity: { control: () => { throw new Error('unused') }, observe: () => { throw new Error('unused') } }, - } - const stream = createActivityRosterStream(remote as never, model) - model.replaceBaseline([row()]) - stream.start() - await stream.dispose() - expect(model.getSnapshot().rowsBySession['']?.[0]?.label).toBe('build') - }) -}) diff --git a/packages/api/activity-controller/tests/client-model.client.spec.ts b/packages/api/activity-controller/tests/client-model.client.spec.ts deleted file mode 100644 index 31f5d18c33..0000000000 --- a/packages/api/activity-controller/tests/client-model.client.spec.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { ClientActivityModel } from '../src/client/model.ts' -import type { ActivityId, ActivityRow } from '../src/types.ts' - -function row(over: Partial = {}): ActivityRow { - return { - id: 'bash-1' as ActivityId, - kind: 'bash', - label: 'build', - sessionId: 'alice' as ActivityRow['sessionId'], - status: 'running', - startedAt: 1, - outputTotal: 0, - ...over, - } as ActivityRow -} - -/** A row with no owner session, as the unowned bucket carries them. */ -function unownedRow(over: Partial = {}): ActivityRow { - const { sessionId: _drop, ...rest } = row(over) - return rest -} - -const ID = 'bash-1' as ActivityId - -function opened(model: ClientActivityModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { - model.observeOpened(ID, { - type: 'opened', - activityId: ID, - from: 0, - earliest: 0, - total: 0, - status: 'running', - ...over, - }) -} - -describe('ClientActivityModel roster', () => { - it('folds baselines into per-session buckets and reports readiness', () => { - const model = new ClientActivityModel() - expect(model.getSnapshot().phase).toBe('pending') - model.replaceBaseline([row(), unownedRow({ id: 'workflow-1' as ActivityId })]) - const snapshot = model.getSnapshot() - expect(snapshot.phase).toBe('ready') - expect(snapshot.rowsBySession['alice']).toHaveLength(1) - expect(snapshot.rowsBySession['']).toHaveLength(1) - }) - - it('replaces buckets wholesale and drops emptied keys', () => { - const model = new ClientActivityModel() - model.replaceBaseline([row()]) - model.replaceBucket('alice', [row({ status: 'completed', finishedAt: 2 })]) - expect(model.getSnapshot().rowsBySession['alice']?.[0]?.status).toBe('completed') - model.replaceBucket('alice', []) - expect('alice' in model.getSnapshot().rowsBySession).toBe(false) - model.replaceBucket(undefined, [unownedRow()]) - expect(model.getSnapshot().rowsBySession['']).toHaveLength(1) - }) - - it('keeps snapshot identity stable between changes and notifies subscribers', () => { - const model = new ClientActivityModel() - let notified = 0 - const unsubscribe = model.subscribe(() => { notified += 1 }) - const before = model.getSnapshot() - expect(model.getSnapshot()).toBe(before) - model.replaceBaseline([row()]) - expect(notified).toBe(1) - expect(model.getSnapshot()).not.toBe(before) - unsubscribe() - model.replaceBucket('alice', []) - expect(notified).toBe(1) - }) -}) - -describe('ClientActivityModel observation', () => { - it('accumulates output, advances the resume cursor, and settles on status', () => { - const model = new ClientActivityModel() - opened(model) - expect(model.cursorOf(ID)).toBe(0) - model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 }) - model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 }) - expect(model.cursorOf(ID)).toBe(3) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.text).toBe('abc') - expect(view?.streaming).toBe(true) - model.observeStatus(ID, { type: 'status', status: 'completed', detail: 'exit code: 0' }) - const settled = model.getSnapshot().observed[String(ID)] - expect(settled?.streaming).toBe(false) - expect(settled?.status).toBe('completed') - expect(settled?.detail).toBe('exit code: 0') - }) - - it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => { - const model = new ClientActivityModel() - opened(model, { from: 4, earliest: 8, total: 10 }) - expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - - const clean = new ClientActivityModel() - opened(clean) - clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 }) - expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - - const lossy = new ClientActivityModel() - opened(lossy) - lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true }) - expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - }) - - it('marks a fresh observation anchored past the evicted head', () => { - // Fresh observations anchor at the registry's earliest retained byte, so - // from === earliest > 0 means the head was already discarded. - const fresh = new ClientActivityModel() - opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 }) - expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - - // A retry that accumulated no text yet earns the mark the same way. - const retried = new ClientActivityModel() - opened(retried) - expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false) - opened(retried, { from: 6, earliest: 6, total: 6 }) - expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - }) - - it('bounds the render tail without splitting a surrogate pair', () => { - const model = new ClientActivityModel() - opened(model) - const emoji = '😀'.repeat((64 * 1024) + 8) - model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 }) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.gapBefore).toBe(true) - expect(view!.text.length).toBeLessThanOrEqual(128 * 1024) - // The bound landed between pairs: the surviving text still round-trips. - expect(/^(?:😀)+$/u.test(view!.text)).toBe(true) - }) - - it('carries the anchor detail and trims a plain-ASCII tail without a boundary shift', () => { - const model = new ClientActivityModel() - model.observeOpened(ID, { - type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running', detail: 'exit soon', - }) - expect(model.getSnapshot().observed[String(ID)]?.detail).toBe('exit soon') - const long = 'x'.repeat((128 * 1024) + 5) - model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length }) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.text.length).toBe(128 * 1024) - expect(view?.gapBefore).toBe(true) - }) - - it('advances the cut past a low surrogate landing exactly on the bound', () => { - const model = new ClientActivityModel() - opened(model) - // 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index. - const text = 'z😀' + 'a'.repeat((128 * 1024) - 1) - model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length }) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.text.length).toBe((128 * 1024) - 1) - expect(view?.text.startsWith('a')).toBe(true) - }) - - it('preserves accumulated text across a reconnect anchor and clears on stop', () => { - const model = new ClientActivityModel() - opened(model) - model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 }) - opened(model, { from: 4, earliest: 0, total: 4 }) - expect(model.getSnapshot().observed[String(ID)]?.text).toBe('kept') - model.observeStopped(ID) - expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() - expect(model.cursorOf(ID)).toBeUndefined() - // A second stop is inert. - model.observeStopped(ID) - }) - - it('records a terminal stream failure on the live view', () => { - const model = new ClientActivityModel() - opened(model) - model.observeFailed(ID, new Error('carrier gone')) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.streaming).toBe(false) - expect(view?.error).toContain('carrier gone') - // A failure for an untracked id is inert. - model.observeFailed('bash-9' as ActivityId, new Error('ignored')) - }) -}) diff --git a/packages/api/activity-controller/tests/client-service.client.spec.ts b/packages/api/activity-controller/tests/client-service.client.spec.ts deleted file mode 100644 index ba051d2e23..0000000000 --- a/packages/api/activity-controller/tests/client-service.client.spec.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { ClientActivityModel } from '../src/client/model.ts' -import { ClientActivityFeed } from '../src/client/service.ts' -import type { ActivityId, ActivityObserveFrame } from '../src/types.ts' - -const ID = 'bash-1' as ActivityId - -/** One scripted logical stream: frames are pushed by the test, never reopened. */ -class FakeStream { - disposed = false - /** Model a carrier whose disposal surfaces as an iterator throw. */ - throwOnDispose = false - /** Hold the dispose promise open until {@link releaseDispose}. */ - deferDispose = false - private disposeRelease: (() => void) | undefined - private disposePending: Promise | undefined - private readonly frames: ActivityObserveFrame[] = [] - private waiter: (() => void) | undefined - private failure: Error | undefined - readonly accepts: number[] = [] - - push(frame: ActivityObserveFrame): void { - this.frames.push(frame) - this.waiter?.() - } - - poison(error: Error): void { - this.failure = error - this.waiter?.() - } - - dispose(): Promise { - if (this.throwOnDispose && this.failure === undefined) { - this.failure = new Error('carrier tore down') - } else { - this.disposed = true - } - this.waiter?.() - if (this.deferDispose) { - // Repeat disposals (releaser plus the consumer's finally) share one - // deferred promise, so releaseDispose resumes every waiter. - this.disposePending ??= new Promise((resolve) => { this.disposeRelease = resolve }) - return this.disposePending - } - return Promise.resolve() - } - - releaseDispose(): void { - this.disposeRelease?.() - } - - async *[Symbol.asyncIterator]() { - let generation = 1 - while (!this.disposed) { - if (this.failure !== undefined) throw this.failure - const frame = this.frames.shift() - if (frame === undefined) { - await new Promise((resolve) => { this.waiter = resolve }) - continue - } - yield { - generation, - value: frame, - signal: new AbortController().signal, - accept: () => { this.accepts.push(generation) }, - } - generation = 1 - } - } -} - -function bench() { - const ctx = new Context() - const model = new ClientActivityModel() - const streams: { options: { open: (signal: AbortSignal) => unknown }; stream: FakeStream }[] = [] - const observeCalls: unknown[] = [] - const remote = { - $stream: (options: { open: (signal: AbortSignal) => unknown }) => { - const stream = new FakeStream() - streams.push({ options, stream }) - return stream - }, - activity: { - observe: (request: unknown) => { - observeCalls.push(request) - return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } - }, - control: () => { throw new Error('not exercised') }, - }, - } - const feed = new ClientActivityFeed(ctx, remote as never, model) - return { ctx, model, feed, streams, observeCalls } -} - -const tick = () => new Promise(resolve => setTimeout(resolve, 0)) - -describe('ClientActivityFeed observation streams', () => { - it('feeds anchor, output, and terminal frames into the model, then closes', async () => { - const { model, feed, streams } = bench() - feed.observe(ID) - const { stream } = streams[0]! - stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 }) - await tick() - expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi') - expect(stream.accepts).toHaveLength(1) - - stream.push({ type: 'status', status: 'completed' }) - await tick() - expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) - expect(stream.disposed).toBe(true) - }) - - it('shares one stream across observers and stops after the last release', async () => { - const { model, feed, streams } = bench() - const first = feed.observe(ID) - const second = feed.observe(ID) - expect(streams).toHaveLength(1) - streams[0]!.stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - - first() - first() - expect(streams[0]!.stream.disposed).toBe(false) - second() - await tick() - expect(streams[0]!.stream.disposed).toBe(true) - expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() - }) - - it('a late release of a superseded observation neither tears down nor clears its successor', async () => { - const { model, feed, streams } = bench() - const firstStop = feed.observe(ID) - const first = streams[0]!.stream - first.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - first.push({ type: 'status', status: 'completed' }) - await tick() - // A successor observation replaces the settled entry while the first - // observer still holds its releaser. - const secondStop = feed.observe(ID) - const second = streams[1]!.stream - second.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - - firstStop() - await tick() - expect(second.disposed).toBe(false) - expect(model.getSnapshot().observed[String(ID)]).toBeDefined() - - secondStop() - await tick() - expect(second.disposed).toBe(true) - expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() - }) - - it('a re-observation inside the dispose round-trip keeps its fresh view', async () => { - const { model, feed, streams } = bench() - const stop = feed.observe(ID) - const first = streams[0]!.stream - first.deferDispose = true - first.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - - stop() - // Re-expand while the previous generation's dispose is still in flight. - feed.observe(ID) - const second = streams[1]!.stream - second.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - - first.releaseDispose() - await tick() - // The stale post-dispose clear must not blank the successor's view. - expect(model.getSnapshot().observed[String(ID)]).toBeDefined() - second.push({ type: 'output', chunks: [{ at: 0, text: 'alive' }], next: 5 }) - await tick() - expect(model.getSnapshot().observed[String(ID)]?.text).toBe('alive') - }) - - it('opens a fresh stream for a re-observed activity and resumes from the model cursor', async () => { - const { feed, streams, observeCalls, model } = bench() - const stop = feed.observe(ID) - streams[0]!.stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 }) - await tick() - // The generation opener reads the live cursor at open time. - streams[0]!.options.open(new AbortController().signal) - expect(observeCalls.at(-1)).toEqual({ activityId: ID, from: 3 }) - - stop() - await tick() - feed.observe(ID) - expect(streams).toHaveLength(2) - streams[1]!.options.open(new AbortController().signal) - // Stopped observation dropped the cursor: the fresh stream starts unanchored. - expect(observeCalls.at(-1)).toEqual({ activityId: ID }) - expect(model.cursorOf(ID)).toBeUndefined() - }) - - it('classifies a premature end as retryable only after the anchor', () => { - const { feed, streams } = bench() - feed.observe(ID) - const options = streams[0]!.options as unknown as { ended: (accepted: boolean) => Error } - expect(options.ended(true).name).toBe('RemoteStreamCarrierError') - expect(options.ended(false).name).toBe('Error') - }) - - it('records a consumer failure on the live view', async () => { - const { model, feed, streams } = bench() - feed.observe(ID) - const { stream } = streams[0]! - stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - stream.poison(new Error('frame decode broke')) - await tick() - const view = model.getSnapshot().observed[String(ID)] - expect(view?.error).toContain('frame decode broke') - expect(view?.streaming).toBe(false) - }) - - it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => { - const { ctx, model, feed, streams } = bench() - const stop = feed.observe(ID) - const { stream } = streams[0]! - stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - // Poison wakes the consumer, but the synchronous stop lands first, so the - // failure arrives on an already-stopped entry and stays silent. - stream.poison(new Error('after stop')) - stop() - await tick() - expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() - - const second = feed.observe(ID) - await ctx.fiber.dispose() - // The disposal path already removed every entry; a live releaser whose - // entry is gone is inert. - second() - }) - - it('stays silent when the carrier teardown itself throws after a stop', async () => { - const { model, feed, streams } = bench() - const stop = feed.observe(ID) - const { stream } = streams[0]! - stream.throwOnDispose = true - stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - // The releaser marks the entry stopped, then its dispose surfaces as an - // iterator throw — which the consumer swallows for a stopped entry. - stop() - await tick() - expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined() - }) - - it('records a terminal stream failure and service disposal closes live streams', async () => { - const { ctx, model, feed, streams } = bench() - feed.observe(ID) - const failing = streams[0]!.stream - failing.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - const iterator = failing[Symbol.asyncIterator]() - void iterator - // Simulate a terminal consumer failure by disposing the underlying stream: - // the consumer loop ends without a status frame and cleans up. - const spy = vi.spyOn(model, 'observeFailed') - await failing.dispose() - await tick() - - const other = 'bash-2' as ActivityId - feed.observe(other) - await ctx.fiber.dispose() - expect(streams.every(entry => entry.stream.disposed)).toBe(true) - expect(spy).not.toHaveBeenCalled() - }) -}) diff --git a/packages/api/activity-controller/tests/controller.host.spec.ts b/packages/api/activity-controller/tests/controller.host.spec.ts deleted file mode 100644 index 4c3262f093..0000000000 --- a/packages/api/activity-controller/tests/controller.host.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' -import { ActivityController } from '../src/index.ts' -import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts' - -async function collect( - iterable: AsyncIterable, - count: number, - abort: AbortController, -): Promise { - const frames: Frame[] = [] - const iterator = iterable[Symbol.asyncIterator]() - try { - while (frames.length < count) { - const step = await iterator.next() - if (step.done) return frames - frames.push(step.value) - } - } finally { - abort.abort() - await iterator.next().catch(() => undefined) - } - return frames -} - -describe('ActivityController', () => { - it('closes live roster followers when the owning fiber unwinds', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalActivityRegistry) - const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) - const abort = new AbortController() - const iterator = controller.control(abort.signal)[Symbol.asyncIterator]() - expect((await iterator.next()).value).toMatchObject({ type: 'baseline' }) - const pending = iterator.next() - await ctx.fiber.dispose() - // The feed effect closed the follower, ending the generation cleanly. - expect((await pending).done).toBe(true) - }) - - it('coalesces bursts that land inside the flush window and carries the anchor detail', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalActivityRegistry) - const controller = new ActivityController(ctx, { flushMs: 30, maxFrameBytes: 1024 }) - const handle = ctx.activities.open({ kind: 'bash', label: 'burst' }) - handle.updateDetail('warming up') - const abort = new AbortController() - const frames: ActivityObserveFrame[] = [] - const done = (async () => { - for await (const frame of controller.observe({ activityId: handle.id }, abort.signal)) frames.push(frame) - })() - await new Promise(resolve => setTimeout(resolve, 10)) - handle.append('a') - await new Promise(resolve => setTimeout(resolve, 5)) - // Lands inside the flush sleep, so the waiter is already dirty at the next wait. - handle.append('b') - await new Promise(resolve => setTimeout(resolve, 80)) - handle.end({ status: 'completed' }) - await done - expect(frames[0]).toMatchObject({ type: 'opened', detail: 'warming up' }) - const text = frames.filter(frame => frame.type === 'output') - .flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') - expect(text).toBe('ab') - }) - - it('serves an empty baseline and refuses observation without the registry', async () => { - const ctx = new Context() - const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) - const abort = new AbortController() - const frames = await collect(controller.control(abort.signal), 1, abort) - expect(frames).toEqual([{ type: 'baseline', activities: [] }]) - expect(() => controller.observe({ activityId: 'bash-1' as never }, new AbortController().signal)) - .toThrow(/activity observation unavailable/) - }) - - it('streams the roster and one activity end to end over a live registry', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalActivityRegistry) - const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) - - const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) - handle.append('hi') - handle.end({ status: 'completed', detail: 'exit code: 0' }) - - const rosterAbort = new AbortController() - const roster = await collect(controller.control(rosterAbort.signal), 1, rosterAbort) - expect(roster[0]).toMatchObject({ type: 'baseline' }) - expect((roster[0] as Extract).activities[0]?.label).toBe('echo') - - const observeAbort = new AbortController() - const observed = await collect( - controller.observe({ activityId: handle.id }, observeAbort.signal), - 3, - observeAbort, - ) - expect(observed.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) - }) -}) diff --git a/packages/api/activity-controller/tsconfig.client.json b/packages/api/activity-controller/tsconfig.client.json deleted file mode 100644 index d2934629b4..0000000000 --- a/packages/api/activity-controller/tsconfig.client.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types", - "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" - }, - "files": [ - "src/client/index.ts", - "src/client/model.ts", - "src/client/service.ts", - "src/types.ts" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../gateway/tsconfig.client.json" - }, - { - "path": "../../client/connection/tsconfig.client.json" - }, - { - "path": "../../client/store" - }, - { - "path": "../../activity/activity" - }, - { - "path": "../../core/session" - }, - { - "path": "../../jobs/jobs" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../util/brand" - }, - { - "path": "../../typert/protocol" - } - ] -} diff --git a/packages/api/activity-controller/tsconfig.host.json b/packages/api/activity-controller/tsconfig.host.json deleted file mode 100644 index 4193d47109..0000000000 --- a/packages/api/activity-controller/tsconfig.host.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types", - "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" - }, - "files": [ - "src/index.ts", - "src/invariant.ts", - "src/types.ts", - "src/feed.ts", - "src/observe.ts" - ], - "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../activity/activity" }, - { "path": "../../core/agent" }, - { "path": "../../core/session" }, - { "path": "../../jobs/jobs" }, - { "path": "../../llm/llm" }, - { "path": "../../runtime-diagnostics/invariants" }, - { "path": "../../typert/protocol" } - ] -} diff --git a/packages/api/activity-controller/tsconfig.json b/packages/api/activity-controller/tsconfig.json deleted file mode 100644 index 2a0b0e33f7..0000000000 --- a/packages/api/activity-controller/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.host.json" }, - { "path": "./tsconfig.client.json" } - ] -} diff --git a/packages/api/activity-controller/tsdown.config.ts b/packages/api/activity-controller/tsdown.config.ts deleted file mode 100644 index 0552d1b1cf..0000000000 --- a/packages/api/activity-controller/tsdown.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { clientBundle } from '../../client/tsdown.client.ts' - -export default clientBundle( - '@deepseek-ai/dsh-api-activity-controller', - ['lib/types/index.js', 'lib/types/invariant.js'], - { hostPhase: true }, -) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index f6f3b74990..a4334e0e75 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -66,7 +66,6 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index baaccbbdbf..e7fc7757e9 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -13,7 +13,6 @@ import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' -import activityRemote from '@deepseek-ai/dsh-api-activity-controller/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -32,8 +31,6 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/remote' export type * from '@deepseek-ai/dsh-api-session-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' -export type {} from '@deepseek-ai/dsh-api-activity-controller/remote' -export type * from '@deepseek-ai/dsh-api-activity-controller/types' export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. @@ -149,7 +146,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, - subagentsRemote, sessionRemote, workspaceRemote, activityRemote, + subagentsRemote, sessionRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index f19acea2c2..5a11bae49d 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -26,7 +26,6 @@ import type {} from '@deepseek-ai/dsh-settings/types' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-user-questions' export type {} from '@deepseek-ai/dsh-api-session-controller/types' -export type {} from '@deepseek-ai/dsh-api-activity-controller/types' export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' export type { ApiRemoteForwardedEvent } from './types.ts' diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 1b4d4ca1ae..56f664d499 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -66,9 +66,6 @@ { "path": "../session-controller/tsconfig.client.json" }, - { - "path": "../activity-controller/tsconfig.client.json" - }, { "path": "../settings-controller" }, diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index a0aab5ea93..65fca35ee0 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -60,9 +60,6 @@ { "path": "../session-controller/tsconfig.host.json" }, - { - "path": "../activity-controller/tsconfig.host.json" - }, { "path": "../workspace-controller/tsconfig.host.json" }, diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 2f2192cde2..2e5ca38bf5 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,6 +29,8 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. +A `SessionJob` row carries `outputTotal` exactly when the job declared an observation record, and `session.observeJob` streams that record from an absolute byte offset — one `opened` anchor, coalesced `output` frames, then one terminal `status` — with the fenced read resolved from the request's session. Record reads are non-consuming: the model-facing cursor and completion-notice state never observe them. The Client adapter exposes `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views a task-list UI renders; observers share one stream per job and resume from their model cursor across reconnects. + The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. ----- @@ -40,6 +42,8 @@ The Session object also carries local submission echoes: `session.beginSubmissio |---|---:|---| | `coldBlankProbeMaxBytes` | `1,024` | Maximum physical size of a cold Session artifact eligible for blankness verification; `0` disables probes | | `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener | +| `observeFlushMs` | `100` | Coalescing window after new record output before a job-observation read, in milliseconds | +| `observeMaxFrameBytes` | `65,536` | Soft byte budget per job-observation output frame; one larger chunk ships whole | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc. diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index adec1a5b4b..eaa8457e88 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -81,11 +81,6 @@ - id: jobs name: '@deepseek-ai/dsh-jobs-local' - # Live-activity observation registry: non-consuming output streams for the - # optional activity plane (background bash/pwsh mirror into it when loaded). - - id: activity - name: '@deepseek-ai/dsh-activity-local' - - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 50a3583899..a203607a5d 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -41,7 +41,6 @@ "dependencies": { "@deepseek-ai/cordis-plugin-hmr": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", - "@deepseek-ai/dsh-activity-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-agent-instructions": "workspace:^", diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 510d0653f3..85b67e7d08 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -95,10 +95,6 @@ - id: workspace-controller name: '@deepseek-ai/dsh-api-workspace-controller' - # Live-activity roster and per-activity output observation over Typert Remote. - - id: activity-controller - name: '@deepseek-ai/dsh-api-activity-controller' - - id: cordis-host-runner name: '@deepseek-ai/dsh-cordis-host-runner' @@ -258,10 +254,10 @@ name: '@deepseek-ai/dsh-client-ui-reference' - # Live activities: the session-header roster with on-demand streaming - # output panels over the activity feed. - - id: ui-activity - name: '@deepseek-ai/dsh-client-ui-activity' + # Background jobs: the session-header roster with on-demand streaming + # record panels over the session job-output service. + - id: ui-jobs + name: '@deepseek-ai/dsh-client-ui-jobs' # Read-only active Schedule catalog. The shipped Web graph resolves the # client package but leaves it disabled; the explicit Schedule overlay diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index b6e34b5a79..0a58a1a78c 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -45,7 +45,6 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", @@ -55,7 +54,7 @@ "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-ui-activity": "workspace:^", + "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-approval": "workspace:^", "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", diff --git a/packages/client/README.md b/packages/client/README.md index 3e45bac9b0..367a6d39ce 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -55,7 +55,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions | — | | [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source | — | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references | — | -| [`ui-activity/`](ui-activity/README.md) | Lists this session's tasks — jobs joined with their live-output activities | — | +| [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs with on-demand streaming record panels | — | | [`ui-schedule/`](ui-schedule/README.md) | Lists the current Session's active reminders in a read-only header catalog | — | | [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces | — | | [`ui-permission-presets/`](ui-permission-presets/README.md) | Configures default permissions and switches the current session's access | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index e624cd5f87..05a0143ce4 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -55,7 +55,7 @@ kind: "package-group" | [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用 | — | | [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source | — | | [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent(子智能体)导航、子级 transcript(文本记录)状态与内联引用 | — | -| [`ui-activity/`](ui-activity/README.zh.md) | 列出本会话的任务——后台任务与其实时输出 activity 的合并视图 | — | +| [`ui-jobs/`](ui-jobs/README.zh.md) | 列出本会话的后台任务,并提供按需展开的流式输出面板 | — | | [`ui-schedule/`](ui-schedule/README.zh.md) | 在只读标题栏目录中列出当前 Session 的活动提醒 | — | | [`ui-model-selection/`](ui-model-selection/README.zh.md) | 在对话界面中提供模型选择 | — | | [`ui-permission-presets/`](ui-permission-presets/README.zh.md) | 配置默认权限并切换当前会话的访问模式 | — | diff --git a/packages/client/ui-activity/README.md b/packages/client/ui-activity/README.md deleted file mode 100644 index 6630558922..0000000000 --- a/packages/client/ui-activity/README.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -description: "The session-header task list: background jobs joined per row with their live-output activities, expandable streaming panels, running/finished sections, and the graceful no-registry degradation." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-client-ui-activity - -English | [中文](README.zh.md) - -## Summary - -`dsh-client-ui-activity` puts this session's running work in one header control: background jobs joined per row with their correlated live-output activities, plus standalone activities such as workflow runs. Expanding an observable row streams its real output into an embedded terminal panel while it runs; collapsing stops the stream, so output only flows while someone is watching. Live rows lead with a ticking duration over a kind-and-status second line, settled rows compact behind a section heading, and without the activity registry the job rows still render — the control degrades to a plain job list rather than disappearing. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Load the plugin through the web-app manifest; it renders nothing until the session can see at least one task, so an ordinary conversation never grows a control for a capability it is not using. - -### The per-row join - -Jobs and activities stay separate planes ([why](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md)); the join happens per row in this component. A job row keeps the job's lifecycle, duration, and model-visible `detail` from the `jobsBySession` mirror, and gains an expandable output panel when an activity carries its `correlation.jobId`; an activity without a job keeps its own row. Rows without an observable activity render as static rows with no expansion affordance. - -### The expanded panel - -Expanding an observable row opens that activity's observation stream from `ctx.activityFeed` into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel. - ------ - - -## Understand the implementation - -
        -Implementation internals — click to expand - -One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.activityFeed` and the standard `useSessions` hook — the component holds no transport state. Observation follows visibility: one `useEffect` opens the stream for the expanded row's activity and closes it on collapse, unmount, or popover dismissal. - -| File | Role | -|---|---| -| [`src/client/ActivityListAction.tsx`](src/client/ActivityListAction.tsx) | The merged task list: join, sections, durations, panels | -| [`src/client/index.ts`](src/client/index.ts) | Slot registration and the dictionaries | -| [`src/client/locales.ts`](src/client/locales.ts) | The `activity` namespace copy (zh source of truth) | - -
        - ------ - - -## Further Exploration - -- [`dsh-api-activity-controller`](../../api/activity-controller/README.md) — the feed service and streams behind the panel. -- [Unified task list Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-unified-task-list.md) — why the join lives in the UI projection. -- [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures. - ------ - - -## Model Experience - -None, as this package renders host-observed state and live output for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). - -#### KV Cache effect - -None; the package never assembles or sends provider requests. - -## Known Limitations and Deferred Work - - - -These limits define current package constraints, not a task backlog. - -- **No kill control** — cancellation stays with the model's `job_kill`; a human kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). -- **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up. - - -### Dev Note - -
        -Working context for maintainers — click to expand - -None. - -
        diff --git a/packages/client/ui-activity/README.zh.md b/packages/client/ui-activity/README.zh.md deleted file mode 100644 index b02949f251..0000000000 --- a/packages/client/ui-activity/README.zh.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -description: "会话头部任务列表:后台任务与其实时输出 activity 逐行合并、可展开的流式面板、进行中/已结束分组,以及无注册表时的优雅退化。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-client-ui-activity - -[English](README.md) | 中文 - -## 概述 - -`dsh-client-ui-activity` 把本会话正在跑的工作放进一个头部控件:后台任务与其关联的实时输出 activity 逐行合并,外加 workflow 运行等独立 activity。展开可观察的行,其真实输出在运行期间流入内嵌终端面板;收起即停流——只有有人在看时输出才流动。进行中的行以跳动时长为主行、kind 与状态为副行,已结束的行折叠在分组标题之后;没有 activity 注册表时 job 行照常渲染——控件退化为普通任务列表而不是消失。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -经 web-app 清单加载该插件;会话一个任务都看不到时它完全不渲染,普通会话不会为未使用的能力多出控件。 - -### 逐行 join - -jobs 与 activity 保持为两个独立的面([缘由](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md));合并逐行发生在本组件内。job 行保留来自 `jobsBySession` 镜像的生命周期、时长与模型可见 `detail`,当某个 activity 以 `correlation.jobId` 关联到它时获得可展开的输出面板;没有 job 的 activity 保留自己的行。没有可观察 activity 的行渲染为无展开交互的静态行。 - -### 展开的面板 - -展开可观察的行即把该 activity 在 `ctx.activityFeed` 上的观察流打开进内嵌终端面板。面板的复制控件复制命令(而非输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不画自己的运行状态点——上方的行已携带状态。保留缺口与流失败以面板上方的提示呈现。 - ------ - - -## 理解实现 - -
        -实现内幕——点击展开 - -头部操作区里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹层;弹层通过测量锚点把自己贴合进视口。所有数据经 `ctx.activityFeed` 与标准 `useSessions` 钩子到达——组件不持有任何传输状态。观察跟随可见性:一个 `useEffect` 为展开行的 activity 开流,在收起、卸载或弹层关闭时关流。 - -| 文件 | 角色 | -|---|---| -| [`src/client/ActivityListAction.tsx`](src/client/ActivityListAction.tsx) | 合并任务列表:join、分组、时长、面板 | -| [`src/client/index.ts`](src/client/index.ts) | slot 注册与词典 | -| [`src/client/locales.ts`](src/client/locales.ts) | `activity` 命名空间文案(zh 为真源) | - -
        - ------ - - -## 进一步探索 - -- [`dsh-api-activity-controller`](../../api/activity-controller/README.zh.md)——面板背后的 feed 服务与流。 -- [统一任务列表 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md)——join 为何住在 UI 投影层。 -- [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md)——面板所配置的 `TerminalBlock` 表面。 - ------ - - -## 模型体验 - -无:本包只为人渲染宿主观察到的状态与实时输出,不触碰任何 prompt、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 - -#### KV Cache 影响 - -无;本包从不组装或发送 provider 请求。 - -## 已知限制与延期工作 - - - -这些限制是当前包约束,不是任务积压。 - -- **没有 kill 控件**——取消仍走模型的 `job_kill`;人工 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。 -- **不渲染通道标签**——stdout 与 stderr chunk 连接成一条流;分通道着色是后续呈现优化。 - - -### 开发备注 - -
        -维护者的工作上下文——点击展开 - -无。 - -
        diff --git a/packages/client/ui-activity/src/client/index.ts b/packages/client/ui-activity/src/client/index.ts deleted file mode 100644 index 39f43df9e1..0000000000 --- a/packages/client/ui-activity/src/client/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Task-list plugin, browser half: contributes one session-header action that - * renders this session's tasks — background jobs joined with their correlated - * live-output activities, plus standalone activities such as workflow runs. - * Job rows arrive through the `jobsBySession` list mirror; activity rows and - * per-row observation streams arrive through the `activityFeed` client - * service. This plugin holds no transport state of its own. - */ -import type { Context as ClientContext } from '@deepseek-ai/cordis' -import { ActivityListAction } from './ActivityListAction.tsx' -import type { ActivityListInjected } from './ActivityListAction.tsx' -import type {} from '@deepseek-ai/dsh-api-activity-controller/client' -import type {} from '@deepseek-ai/dsh-client-locale/client' -import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' -import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import { en, NS, zh, type ActivityKey } from './locales.ts' - -declare module '@deepseek-ai/dsh-client-ui-slots' { - interface LocaleNamespaceMap { - /** Live-activity list copy. */ - 'activity': ActivityKey - } -} - -export type { ActivityListActionProps, ActivityListInjected } from './ActivityListAction.tsx' - -/** Required services: session and activity state, the slot registry, and dictionaries. */ -export const inject = ['sessions', 'activityFeed', 'slots', 'locale'] - -/** - * Client plugin body: register the dictionaries and the header action. - * @param ctx - client root context. - */ -export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-activity: dictionaries') - ctx.slots.inject( - 'conversation.session.header.actions', - () => ctx.slots.register({ - name: 'conversation.session.header.actions', - id: 'activity-list', - // After the subagent catalog: session lineage reads before running work. - order: 20, - locale: NS, - inject: (): ActivityListInjected => ({ - hooks: { activity: ctx.activityFeed.state }, - observe: id => ctx.activityFeed.observe(id), - }), - }, ActivityListAction), - ) -} diff --git a/packages/client/ui-activity/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml similarity index 70% rename from packages/client/ui-activity/README.i18n.yaml rename to packages/client/ui-jobs/README.i18n.yaml index bae875ee0e..b968d83fab 100644 --- a/packages/client/ui-activity/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/client/ui-activity/README.md -README.md: 6630558922cf9f1e2b772bcc6334a21f91591acb -README.zh.md: b02949f2518c85ba931e0adf76946c390b564d1a +# pnpm run verify-translation-pairing --write packages/client/ui-jobs/README.md +README.md: b93f6b6f9dcb0af26c077634181427105e0b3a1b +README.zh.md: 17809b2203b34bdc78b34984a71fb755d3f67a05 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md new file mode 100644 index 0000000000..b93f6b6f9d --- /dev/null +++ b/packages/client/ui-jobs/README.md @@ -0,0 +1,93 @@ +--- +description: "The session-header background-job list: expandable streaming record panels, running/finished sections, and static rows for jobs without a record." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-jobs + +English | [中文](README.zh.md) + +## Summary + +`dsh-client-ui-jobs` puts this session's background jobs in one header control. Every row carries the job's lifecycle, ticking duration, and model-visible `detail` from the `jobsBySession` mirror; a job that declared an observation record additionally offers an expandable panel that streams its real output while it runs. Collapsing stops the stream, so output only flows while someone is watching. Live rows lead with a ticking duration over a kind-and-status second line; settled rows compact behind a section heading; jobs without a record (subagent delegations, PTY sends) render as static rows with no expansion affordance. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Load the plugin through the web-app manifest; it renders nothing until the session can see at least one job, so an ordinary conversation never grows a control for a capability it is not using. + +### One row per job + +The session control stream's `jobsBySession` mirror is the single roster: each `SessionJob` row carries lifecycle, duration, and the model-visible `detail`, and its `outputTotal` field is present exactly when the job declared a record — that presence is what makes a row expandable. There is no second roster to join. + +### The expanded panel + +Expanding an observable row opens that job's record observation stream from `ctx.jobOutput` into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel. + +----- + + +## Understand the implementation + +
        +Implementation internals — click to expand + +One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobOutput` and the standard `useSessions` hook — the component holds no transport state. Observation follows visibility: one `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover dismissal. + +| File | Role | +|---|---| +| [`src/client/JobListAction.tsx`](src/client/JobListAction.tsx) | The job list: sections, durations, panels | +| [`src/client/index.ts`](src/client/index.ts) | Slot registration and the dictionaries | +| [`src/client/locales.ts`](src/client/locales.ts) | The `job` namespace copy (zh source of truth) | + +
        + +----- + + +## Further Exploration + +- [`dsh-api-session-controller`](../../api/session-controller/README.md) — the `jobsBySession` mirror and the `session.observeJob` stream behind the panel. +- [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the record semantics. +- [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures. + +----- + + +## Model Experience + +None, as this package renders host-observed state and live output for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). + +#### KV Cache effect + +None; the package never assembles or sends provider requests. + +## Known Limitations and Deferred Work + + + +These limits define current package constraints, not a task backlog. + +- **No kill control** — cancellation stays with the model's `job_kill`; a human kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). +- **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up. + + +### Dev Note + +
        +Working context for maintainers — click to expand + +None. + +
        diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md new file mode 100644 index 0000000000..17809b2203 --- /dev/null +++ b/packages/client/ui-jobs/README.zh.md @@ -0,0 +1,93 @@ +--- +description: "会话头部后台任务列表:可展开的流式 record 面板、进行中/已结束分组,以及无 record 任务的静态行。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-jobs + +[English](README.md) | 中文 + +## 概述 + +`dsh-client-ui-jobs` 把本会话的后台任务放进一个头部控件。每一行都携带 `jobsBySession` 镜像里该 job 的生命周期、跳动时长与模型可见的 `detail`;声明了观测 record 的 job 额外提供可展开的面板,在运行期间流式呈现其真实输出。收起即停流——只有有人在看时输出才流动。进行中的行以跳动时长为主行、kind 与状态为副行;已结束的行折叠在分组标题之后;没有 record 的 job(subagent 委托、PTY 发送)渲染为无展开操作的静态行。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +通过 web-app 清单加载本插件;在会话看得到至少一个 job 之前它不渲染任何东西,因此普通对话不会为未使用的能力长出控件。 + +### 一 job 一行 + +session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJob` 行携带生命周期、时长与模型可见的 `detail`,其 `outputTotal` 字段恰在 job 声明了 record 时存在——这个存在性就是行可展开的依据。不存在需要 join 的第二份名册。 + +### 展开的面板 + +展开可观察的行会从 `ctx.jobOutput` 打开该 job 的 record 观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。 + +----- + + +## 理解实现 + +
        +实现内幕——点击展开 + +头部操作带里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobOutput` 与标准 `useSessions` hook 到达——组件不持有任何传输状态。观测跟随可见性:一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 + +| 文件 | 角色 | +|---|---| +| [`src/client/JobListAction.tsx`](src/client/JobListAction.tsx) | 任务列表:分组、时长、面板 | +| [`src/client/index.ts`](src/client/index.ts) | slot 注册与词典 | +| [`src/client/locales.ts`](src/client/locales.ts) | `job` 命名空间文案(zh 为事实源) | + +
        + +----- + + +## 进一步探索 + +- [`dsh-api-session-controller`](../../api/session-controller/README.zh.md) —— 面板背后的 `jobsBySession` 镜像与 `session.observeJob` 流。 +- [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有 record 语义的注册表契约。 +- [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md) —— 面板所配置的 `TerminalBlock` 表面。 + +----- + + +## 模型体验 + +无。本包为人类渲染宿主观测到的状态与实时输出,不触碰任何提示词、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 + +#### KV 缓存影响 + +无;本包从不组装或发送 provider 请求。 + +## 已知限制与延期工作 + + + +这些限制界定当前包约束,不是任务清单。 + +- **没有 kill 控件**——取消仍归模型的 `job_kill`;人工 kill 卡在 [web 任务显示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题上。 +- **不渲染 channel 标签**——stdout 与 stderr 块拼接为一条流;按 channel 着色是展示层的后续工作。 + + +### 开发备注 + +
        +维护者工作语境——点击展开 + +无。 + +
        diff --git a/packages/client/ui-activity/package.json b/packages/client/ui-jobs/package.json similarity index 83% rename from packages/client/ui-activity/package.json rename to packages/client/ui-jobs/package.json index cd558c41d2..5b29f894b6 100644 --- a/packages/client/ui-activity/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-ui-activity", - "description": "Session-header task list: background jobs joined with their live-output activities, with on-demand streaming output panels", + "name": "@deepseek-ai/dsh-client-ui-jobs", + "description": "Session-header background-job list with on-demand streaming record panels", "version": "0.1.2-alpha.2", "type": "module", "main": "lib/index.js", @@ -29,7 +29,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/client/ui-activity" + "directory": "packages/client/ui-jobs" }, "publishConfig": { "access": "public" @@ -39,7 +39,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", @@ -49,6 +48,7 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, @@ -61,7 +61,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-api-activity-controller", + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives" diff --git a/packages/client/ui-activity/src/client/ActivityListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css similarity index 100% rename from packages/client/ui-activity/src/client/ActivityListAction.module.css rename to packages/client/ui-jobs/src/client/JobListAction.module.css diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx similarity index 61% rename from packages/client/ui-activity/src/client/ActivityListAction.tsx rename to packages/client/ui-jobs/src/client/JobListAction.tsx index 41f4d7a4b1..edac6497f6 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,8 +1,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { - ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, -} from '@deepseek-ai/dsh-api-activity-controller/client' +import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconChevronDownOutline14, StateDot, TerminalBlock, useDismissOnOutsidePointer, type StateDotState, type TerminalBlockLabels, @@ -10,74 +9,57 @@ import { import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import css from './ActivityListAction.module.css' +import css from './JobListAction.module.css' -/** Registration-side business face for the activity feed. */ -export interface ActivityListInjected { +/** Registration-side business face for the job list. */ +export interface JobListInjected { hooks: { - /** Client activity snapshot bound by the renderer as useActivity. */ - activity: { - getSnapshot(): ActivityFeedSnapshot + /** Client job-output snapshot bound by the renderer as useJobOutput. */ + jobOutput: { + getSnapshot(): JobOutputSnapshot subscribe(listener: () => void): () => void } } /** - * Start observing one activity's live output; returns the stop function. - * Reference-counted by the client feed, so panels can overlap safely. + * Start observing one job's live record; returns the stop function. + * Reference-counted by the client service, so panels can overlap safely. */ - observe: (id: ActivityId) => () => void + observe: (sessionId: SessionId | undefined, id: SessionJob['id']) => () => void } -/** Full props for the session-header task-list action. */ -export type ActivityListActionProps = +/** Full props for the session-header job-list action. */ +export type JobListActionProps = PropsRuntime<'conversation.session.header.actions'> & PropsLocale - & InjectFace + & InjectFace -/** - * One merged task row. Jobs and activities are separate planes — the control - * plane the model interacts with, and the transient observation plane — so - * this list joins them per row: a job carries identity, lifecycle, and the - * model-visible detail; its correlated activity contributes the observable - * live output; an activity without a job (a workflow run) stands alone. - */ -interface TaskRow { - /** Stable row identity: the job id when one exists, else the activity id. */ - key: string - kind: string - label: string - /** Job lifecycle vocabulary; activity-only rows never reach `stopping`. */ - status: SessionJob['status'] - detail?: string - startedAt: number - finishedAt?: number - /** Present when this row's live output can be observed. */ - activityId?: ActivityId -} - -/** Stable empty lists so a session with no work keeps one array identity. */ -const NO_ROWS: readonly ActivityRow[] = [] +/** Stable empty list so a session with no jobs keeps one array identity. */ const NO_JOBS: readonly SessionJob[] = [] /** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */ const VIEWPORT_MARGIN = 12 -function isLive(row: TaskRow): boolean { - return row.status === 'running' || row.status === 'stopping' +function isLive(job: SessionJob): boolean { + return job.status === 'running' || job.status === 'stopping' +} + +/** True exactly when the job declared an observation record. */ +function isObservable(job: SessionJob): boolean { + return job.outputTotal !== undefined } /** Closed-union exhaustiveness fence for the wire status set. */ /* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ function assertNever(value: never): never { - throw new Error(`unhandled task status: ${JSON.stringify(value)}`) + throw new Error(`unhandled job status: ${JSON.stringify(value)}`) } /** * Status marker semantics. `stopping` and `killed` share the attention color: * both mean the work ended (or is ending) on request rather than on its own. */ -function dotState(status: TaskRow['status']): StateDotState { +function dotState(status: SessionJob['status']): StateDotState { switch (status) { case 'running': return 'ongoing' case 'stopping': return 'warning' @@ -89,7 +71,7 @@ function dotState(status: TaskRow['status']): StateDotState { } } -function statusLabel(status: TaskRow['status'], t: TranslateNS): string { +function statusLabel(status: SessionJob['status'], t: TranslateNS): string { switch (status) { case 'running': return t('status.running') case 'stopping': return t('status.stopping') @@ -102,7 +84,7 @@ function statusLabel(status: TaskRow['status'], t: TranslateNS): stri } /** - * Elapsed time in at most two adjacent units. A task that outlives an hour is + * Elapsed time in at most two adjacent units. A job that outlives an hour is * already exceptional, so hours is the widest unit — beyond that the figure * stays in hours rather than growing a day/month vocabulary no producer * currently reaches. @@ -143,66 +125,13 @@ function terminalLabels(t: TranslateNS): TerminalBlockLabels { } } -/** Project one standalone activity into the merged row vocabulary. */ -function activityTask(activity: ActivityRow): TaskRow { - return { - key: String(activity.id), - kind: activity.kind, - label: activity.label, - status: activity.status, - ...activity.detail !== undefined ? { detail: activity.detail } : {}, - startedAt: activity.startedAt, - ...activity.finishedAt !== undefined ? { finishedAt: activity.finishedAt } : {}, - activityId: activity.id, - } -} - -/** - * Join this session's jobs with their correlated activities and append the - * activities that stand alone. A job row keeps the job's lifecycle and detail - * (what the model sees) and gains observability from its activity; an - * activity whose job is not in the projection keeps its own row rather than - * disappearing. - */ -function mergeRows( - jobs: readonly SessionJob[], - activities: readonly ActivityRow[], -): TaskRow[] { - const byJob = new Map() - const standalone: ActivityRow[] = [] - for (const activity of activities) { - const jobId = activity.correlation?.jobId - if (jobId === undefined) standalone.push(activity) - // One activity per job: every shipped producer opens at most one, so a - // duplicate jobId keeps only the newest row rather than growing the list. - else byJob.set(String(jobId), activity) - } - const rows: TaskRow[] = jobs.map((job) => { - const activity = byJob.get(String(job.id)) - byJob.delete(String(job.id)) - return { - key: String(job.id), - kind: job.kind, - label: job.label, - status: job.status, - ...job.detail !== undefined ? { detail: job.detail } : {}, - startedAt: job.startedAt, - ...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {}, - ...activity !== undefined ? { activityId: activity.id } : {}, - } - }) - for (const activity of byJob.values()) rows.push(activityTask(activity)) - for (const activity of standalone) rows.push(activityTask(activity)) - return rows -} - /** * Live rows first in start order, then settled rows newest-first. Two rows * that settled in the same millisecond fall back to start order, so the sort * never depends on the host's map iteration. */ -function ordered(rows: readonly TaskRow[]): TaskRow[] { - return [...rows].sort((left, right) => { +function ordered(jobs: readonly SessionJob[]): SessionJob[] { + return [...jobs].sort((left, right) => { const liveLeft = isLive(left) if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 if (liveLeft) return left.startedAt - right.startedAt @@ -211,21 +140,21 @@ function ordered(rows: readonly TaskRow[]): TaskRow[] { }) } -/** One task row plus, when observable and expanded, its live output panel. */ -function TaskItem({ row, view, expanded, now, onToggle, t }: { - row: TaskRow - view: ObservedActivity | undefined +/** One job row plus, when observable and expanded, its live output panel. */ +function JobItem({ job, view, expanded, now, onToggle, t }: { + job: SessionJob + view: ObservedJob | undefined expanded: boolean /** Clock sample live rows derive their running duration from. */ now: number onToggle: () => void t: TranslateNS }) { - const live = isLive(row) - const status = statusLabel(row.status, t) - const observable = row.activityId !== undefined + const live = isLive(job) + const status = statusLabel(job.status, t) + const observable = isObservable(job) const labels = useMemo(() => terminalLabels(t), [t]) - const elapsed = live ? now - row.startedAt : (row.finishedAt ?? row.startedAt) - row.startedAt + const elapsed = live ? now - job.startedAt : (job.finishedAt ?? job.startedAt) - job.startedAt const duration = formatDuration(elapsed, t) const durationCell = ( - + - {row.label} + {job.label} {durationCell} - {row.kind} - {row.detail ?? status} + {job.kind} + {job.detail ?? status} {observable ? : null} @@ -254,10 +183,10 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: { ) : ( <> - - {row.kind} - {row.label} - {row.detail ?? status} + + {job.kind} + {job.label} + {job.detail ?? status} {durationCell} {observable ? : null} @@ -270,7 +199,7 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: { type="button" className={live ? css.row : `${css.row} ${css.rowSettled}`} aria-expanded={expanded} - aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: row.label })} + aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: job.label })} onClick={onToggle} > {body} @@ -289,10 +218,10 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: { ?
        {t('output.error', { error: view.error })}
        : null} state.jobsBySession[sessionId]) ?? NO_JOBS - const owned = useActivity(state => state.rowsBySession[sessionId]) ?? NO_ROWS - const unowned = useActivity(state => state.rowsBySession['']) ?? NO_ROWS - const observedViews = useActivity(state => state.observed) + const observedViews = useJobOutput(state => state.observed) const [open, setOpen] = useState(false) const [expandedKey, setExpandedKey] = useState(undefined) const [now, setNow] = useState(() => Date.now()) @@ -333,12 +259,9 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ // inside the viewport (the stylesheet alone cannot see the anchor offset). const [menuShift, setMenuShift] = useState(0) - const rows = useMemo( - () => ordered(mergeRows(jobs, [...owned, ...unowned])), - [jobs, owned, unowned], - ) + const rows = useMemo(() => ordered(jobs), [jobs]) const liveRows = useMemo(() => rows.filter(isLive), [rows]) - const settledRows = useMemo(() => rows.filter(row => !isLive(row)), [rows]) + const settledRows = useMemo(() => rows.filter(job => !isLive(job)), [rows]) useDismissOnOutsidePointer(rootRef, open, setOpen) @@ -378,15 +301,16 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ // Observation follows visibility: the stream opens when an observable panel // expands and closes when it collapses, unmounts, or the popover closes. - const activeActivity = open && expandedKey !== undefined - ? rows.find(row => row.key === expandedKey)?.activityId + const expandedRow = open && expandedKey !== undefined + ? rows.find(job => String(job.id) === expandedKey) : undefined + const activeJob = expandedRow !== undefined && isObservable(expandedRow) ? expandedRow.id : undefined useEffect(() => { - if (activeActivity === undefined) return - return observe(activeActivity) - }, [activeActivity, observe]) + if (activeJob === undefined) return + return observe(sessionId, activeJob) + }, [sessionId, activeJob, observe]) - // The last task disappearing removes this control; close first so focus + // The last job disappearing removes this control; close first so focus // does not vanish from an unmounting node. useEffect(() => { if (rows.length === 0 && open) setOpen(false) @@ -394,7 +318,7 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ // An expanded row that left the list (owner disposal) folds its panel. useEffect(() => { - if (expandedKey !== undefined && !rows.some(row => row.key === expandedKey)) { + if (expandedKey !== undefined && !rows.some(job => String(job.id) === expandedKey)) { setExpandedKey(undefined) } }, [rows, expandedKey]) @@ -413,15 +337,15 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ triggerRef.current?.focus() } - const item = (row: TaskRow) => ( - ( + { - setExpandedKey(current => current === row.key ? undefined : row.key) + setExpandedKey(current => current === String(job.id) ? undefined : String(job.id)) }} t={t} /> @@ -437,7 +361,7 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ aria-label={countLabel} onClick={() => { // Sample the clock in the same commit that opens the list: the - // mount-time value predates every task, so the first painted frame + // mount-time value predates every job, so the first painted frame // would otherwise clamp a long-running row to zero until the // open effect corrects it a frame later. setNow(Date.now()) diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts new file mode 100644 index 0000000000..164b6f6e3c --- /dev/null +++ b/packages/client/ui-jobs/src/client/index.ts @@ -0,0 +1,49 @@ +/** + * Background-job plugin, browser half: contributes one session-header action + * that renders this session's jobs. Job rows arrive through the + * `jobsBySession` list mirror; per-row observation streams arrive through the + * `jobOutput` client service. This plugin holds no transport state of its + * own. + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { JobListAction } from './JobListAction.tsx' +import type { JobListInjected } from './JobListAction.tsx' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import { en, NS, zh, type JobKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Background-job list copy. */ + 'job': JobKey + } +} + +export type { JobListActionProps, JobListInjected } from './JobListAction.tsx' + +/** Required services: session state, job observation, the slot registry, and dictionaries. */ +export const inject = ['sessions', 'jobOutput', 'slots', 'locale'] + +/** + * Client plugin body: register the dictionaries and the header action. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-jobs: dictionaries') + ctx.slots.inject( + 'conversation.session.header.actions', + () => ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'job-list', + // After the subagent catalog: session lineage reads before running work. + order: 20, + locale: NS, + inject: (): JobListInjected => ({ + hooks: { jobOutput: ctx.jobOutput.state }, + observe: (sessionId, id) => ctx.jobOutput.observe(sessionId, id), + }), + }, JobListAction), + ) +} diff --git a/packages/client/ui-activity/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts similarity index 78% rename from packages/client/ui-activity/src/client/locales.ts rename to packages/client/ui-jobs/src/client/locales.ts index d4fb08ec31..d2d36b74d1 100644 --- a/packages/client/ui-activity/src/client/locales.ts +++ b/packages/client/ui-jobs/src/client/locales.ts @@ -1,15 +1,15 @@ -/** `activity` namespace dictionaries. */ +/** `job` namespace dictionaries. */ /** Dictionary namespace owned by this plugin. */ -export const NS = 'activity' +export const NS = 'job' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'count.live.one': '{count} 个任务进行中', - 'count.live.other': '{count} 个任务进行中', - 'count.idle.one': '{count} 个任务', - 'count.idle.other': '{count} 个任务', - 'list.aria': '任务', + 'count.live.one': '{count} 个后台任务运行中', + 'count.live.other': '{count} 个后台任务运行中', + 'count.idle.one': '{count} 个后台任务', + 'count.idle.other': '{count} 个后台任务', + 'list.aria': '后台任务', 'section.live': '进行中', 'section.settled': '已结束', 'row.expandAria': '展开 {label} 的实时输出', @@ -41,12 +41,12 @@ export const zh = { } as const /** English dictionary, key-identical to the Chinese source of truth. */ -export const en: Record = { - 'count.live.one': '{count} task running', - 'count.live.other': '{count} tasks running', - 'count.idle.one': '{count} task', - 'count.idle.other': '{count} tasks', - 'list.aria': 'Tasks', +export const en: Record = { + 'count.live.one': '{count} background job running', + 'count.live.other': '{count} background jobs running', + 'count.idle.one': '{count} background job', + 'count.idle.other': '{count} background jobs', + 'list.aria': 'Background jobs', 'section.live': 'Running', 'section.settled': 'Finished', 'row.expandAria': 'Show live output of {label}', @@ -77,5 +77,5 @@ export const en: Record = { 'terminal.expandAria': 'Expand {n} collapsed output lines', } -/** Key domain of the `activity` namespace (zh is the source of truth). */ -export type ActivityKey = keyof typeof zh +/** Key domain of the `job` namespace (zh is the source of truth). */ +export type JobKey = keyof typeof zh diff --git a/packages/client/ui-activity/src/css-modules.d.ts b/packages/client/ui-jobs/src/css-modules.d.ts similarity index 100% rename from packages/client/ui-activity/src/css-modules.d.ts rename to packages/client/ui-jobs/src/css-modules.d.ts diff --git a/packages/client/ui-activity/src/index.ts b/packages/client/ui-jobs/src/index.ts similarity index 66% rename from packages/client/ui-activity/src/index.ts rename to packages/client/ui-jobs/src/index.ts index f75578ddaf..457e7eeb9c 100644 --- a/packages/client/ui-activity/src/index.ts +++ b/packages/client/ui-jobs/src/index.ts @@ -1,8 +1,8 @@ /** - * Node half of the live-activity list plugin. The browser half in + * Node half of the background-job list plugin. The browser half in * `src/client/` owns every contribution; this entry exists so the package * appears as an ordinary Loader row. - * @module @deepseek-ai/dsh-client-ui-activity + * @module @deepseek-ai/dsh-client-ui-jobs */ /** Loader-visible no-op body; the browser half carries the feature. */ diff --git a/packages/client/ui-activity/src/invariant.ts b/packages/client/ui-jobs/src/invariant.ts similarity index 70% rename from packages/client/ui-activity/src/invariant.ts rename to packages/client/ui-jobs/src/invariant.ts index 22eba24c42..51ae72f998 100644 --- a/packages/client/ui-activity/src/invariant.ts +++ b/packages/client/ui-jobs/src/invariant.ts @@ -1,20 +1,20 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-activity`. - * @module @deepseek-ai/dsh-client-ui-activity/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-jobs`. + * @module @deepseek-ai/dsh-client-ui-jobs/invariant */ /* jscpd:ignore-start */ import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-activity' +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-jobs' /** Cordis companion plugin name. */ -export const name = 'client-ui-activity-invariant' +export const name = 'client-ui-jobs-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** No runtime invariant: pure presentation over the activity feed's client snapshot; the feed's own packages assert the data relations. */ +/** No runtime invariant: pure presentation over client snapshots; the registry packages assert the data relations. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts similarity index 73% rename from packages/client/ui-activity/tests/browser-plugin.client.spec.ts rename to packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index 3f84c2252d..8de72f8ccd 100644 --- a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -1,5 +1,5 @@ /** - * ui-activity plugin halves: the browser entry's dictionary and header-slot + * ui-jobs plugin halves: the browser entry's dictionary and header-slot * registrations against the real SlotRegistry (with fiber teardown proving * removal — HMR safety), the inert node entry, and the invariant companion's * ownership reservation. @@ -12,7 +12,7 @@ import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' -import * as ActivityInvariant from '../src/invariant.ts' +import * as JobsInvariant from '../src/invariant.ts' import { en, NS, zh } from '../src/client/locales.ts' /** Slot ledger reader: entry ids currently registered in the header list. */ @@ -22,8 +22,8 @@ function headerEntryIds(ctx: Context): (string | undefined)[] { .map(entry => entry.options.id) } -/** Ids handed to the stubbed feed's observe control. */ -const observed: string[] = [] +/** Observation requests handed to the stubbed job-output control. */ +const observed: [string | undefined, string][] = [] /** Boot the browser half over a real slot tree that declares the header list. */ async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { @@ -36,9 +36,12 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) ctx.provide('sessions', {} as never) - ctx.provide('activityFeed', { - state: { getSnapshot: () => ({ rowsBySession: {}, observed: {}, phase: 'pending' }), subscribe: () => () => {} }, - observe: (id: string) => { observed.push(id); return () => {} }, + ctx.provide('jobOutput', { + state: { getSnapshot: () => ({ observed: {} }), subscribe: () => () => {} }, + observe: (sessionId: string | undefined, id: string) => { + observed.push([sessionId, id]) + return () => {} + }, } as never) // The locale plugin binds a settings scope, which reads the connection handle // and the forwarded-event port. @@ -55,30 +58,33 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType { +describe('ui-jobs browser half', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['sessions', 'activityFeed', 'slots', 'locale']) + expect(inject).toEqual(['sessions', 'jobOutput', 'slots', 'locale']) }) - it('exposes the feed source and observation control through the inject face', async () => { + it('exposes the output source and observation control through the inject face', async () => { const { ctx } = await bench() const entry = ctx.slots .entries('conversation.session.header.actions') - .find(candidate => candidate.options.id === 'activity-list') + .find(candidate => candidate.options.id === 'job-list') const face = (entry as unknown as { - inject: () => { hooks: { activity: unknown }; observe: (id: string) => () => void } + inject: () => { + hooks: { jobOutput: unknown } + observe: (sessionId: string | undefined, id: string) => () => void + } }).inject() - expect(face.hooks.activity).toBeDefined() - const stopper = face.observe('bash-1') - expect(observed).toEqual(['bash-1']) + expect(face.hooks.jobOutput).toBeDefined() + const stopper = face.observe('session', 'bash-1') + expect(observed).toEqual([['session', 'bash-1']]) stopper() }) it('registers the header action, and fiber teardown removes it (HMR safety)', async () => { const { ctx, fiber } = await bench() - expect(headerEntryIds(ctx)).toContain('activity-list') + expect(headerEntryIds(ctx)).toContain('job-list') await fiber.dispose() - expect(headerEntryIds(ctx)).not.toContain('activity-list') + expect(headerEntryIds(ctx)).not.toContain('job-list') }) it('registers both dictionaries under its own namespace and releases them with the fiber', async () => { @@ -98,19 +104,19 @@ describe('ui-activity browser half', () => { }) }) -describe('ui-activity node half', () => { +describe('ui-jobs node half', () => { it('contributes no host behavior', () => { // The node half exists only so the plugin appears in the Loader tree. expect(applyNode).not.toThrow() }) }) -describe('ui-activity invariant companion', () => { +describe('ui-jobs invariant companion', () => { it('reserves package ownership under its declared companion name', async () => { const ctx = new Context() await ctx.plugin(InvariantRegistry, { enabled: true }) - await ctx.plugin(ActivityInvariant) - expect(ActivityInvariant.name).toBe('client-ui-activity-invariant') - expect(ActivityInvariant.inject).toEqual(['invariants']) + await ctx.plugin(JobsInvariant) + expect(JobsInvariant.name).toBe('client-ui-jobs-invariant') + expect(JobsInvariant.inject).toEqual(['invariants']) }) }) diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx similarity index 59% rename from packages/client/ui-activity/tests/activity-list-action.client.spec.tsx rename to packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index ffb4ce5492..15bd8ec4a9 100644 --- a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -2,12 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { - ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, -} from '@deepseek-ai/dsh-api-activity-controller/client' +import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { ActivityListAction, type ActivityListActionProps } from '../src/client/ActivityListAction.tsx' +import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' import { zh } from '../src/client/locales.ts' afterEach(() => { @@ -17,26 +15,7 @@ afterEach(() => { }) const SESSION = 'session' as SessionId -const t: ActivityListActionProps['t'] = makeTranslate(zh) - -function row(over: Partial = {}): ActivityRow { - return { - id: 'bash-1' as ActivityId, - kind: 'bash', - label: 'pnpm run build', - sessionId: SESSION, - status: 'running', - startedAt: 1_700_000_000_000, - outputTotal: 0, - ...over, - } -} - -/** A row with no owner session, as the unowned bucket carries them. */ -function unownedRow(over: Partial = {}): ActivityRow { - const { sessionId: _drop, ...rest } = row(over) - return rest -} +const t: JobListActionProps['t'] = makeTranslate(zh) function job(over: Partial = {}): SessionJob { return { @@ -49,117 +28,88 @@ function job(over: Partial = {}): SessionJob { } } +/** A job that declared an observation record, so its row offers a panel. */ +function recordJob(over: Partial = {}): SessionJob { + return job({ outputTotal: 0, ...over }) +} + function props( - snapshot: Partial, - observe: ActivityListActionProps['observe'] = () => () => {}, - jobs: readonly SessionJob[] = [], -): ActivityListActionProps { - const state: ActivityFeedSnapshot = { - rowsBySession: {}, - observed: {}, - phase: 'ready', - ...snapshot, - } - function useActivity(select: (value: ActivityFeedSnapshot) => T): T { - return select(state) + jobs: readonly SessionJob[], + observe: JobListActionProps['observe'] = () => () => {}, + observed: Readonly> = {}, +): JobListActionProps { + const outputState: JobOutputSnapshot = { observed } + function useJobOutput(select: (value: JobOutputSnapshot) => T): T { + return select(outputState) } const sessionsState = { jobsBySession: jobs.length > 0 ? { [SESSION]: jobs } : {} } function useSessions(select: (value: typeof sessionsState) => T): T { return select(sessionsState) } - return { sessionId: SESSION, useSessions, useActivity, observe, t } as unknown as ActivityListActionProps + return { sessionId: SESSION, useSessions, useJobOutput, observe, t } as unknown as JobListActionProps } function openList(): void { fireEvent.click(screen.getAllByRole('button')[0]!) } -describe('ActivityListAction visibility', () => { - it('renders nothing while the session sees no tasks', () => { - const { container } = render() +describe('JobListAction visibility', () => { + it('renders nothing while the session sees no jobs', () => { + const { container } = render() expect(container.innerHTML).toBe('') }) - it('counts live tasks on the trigger, merging owned and unowned activity rows', () => { - render() - expect(screen.getByRole('button', { name: '2 个任务进行中' })).toBeDefined() + it('counts live jobs on the trigger', () => { + render() + expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() }) - it('falls back to the total when nothing is live and hides foreign sessions', () => { - render() - expect(screen.getByRole('button', { name: '1 个任务' })).toBeDefined() + it('falls back to the total when nothing is live', () => { + render() + expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() }) }) -describe('ActivityListAction merged rows', () => { - it('joins a job with its correlated activity: job lifecycle, activity output', () => { - const observe = vi.fn(() => () => {}) - render() - openList() - const list = screen.getByRole('list', { name: zh['list.aria'] }) - // One merged row: the job's label and lifecycle word, not the activity's. - expect(within(list).getAllByRole('listitem')).toHaveLength(1) - expect(within(list).getByText('pnpm run build')).toBeDefined() - expect(screen.queryByText('activity-side label')).toBeNull() - expect(within(list).getByText('winding down')).toBeDefined() - // Expansion observes the correlated activity's stream. - fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - expect(observe).toHaveBeenCalledWith('bash-act-7') - }) - - it('renders a bare job row without expansion affordances', () => { - render( { + it('renders a record-less job row without expansion affordances', () => { + render() - expect(screen.getByRole('button', { name: '1 个任务进行中' })).toBeDefined() + expect(screen.getByRole('button', { name: '1 个后台任务运行中' })).toBeDefined() openList() expect(screen.getByText('explore the repo')).toBeDefined() expect(screen.queryByRole('button', { name: zh['row.expandAria'].replace('{label}', 'explore the repo') })).toBeNull() }) - it('keeps an activity whose job is not in the projection', () => { - render() + it('shows the live detail line in place of the status word', () => { + render() openList() - expect(screen.getByText('pnpm run build')).toBeDefined() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + expect(within(list).getAllByRole('listitem')).toHaveLength(1) + expect(within(list).getByText('pnpm run build')).toBeDefined() + expect(within(list).getByText('winding down')).toBeDefined() }) it('labels each non-empty section and drops the heading of an empty one', () => { const settled = [job({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 1_700_000_012_000 })] - const { rerender } = render() + const { rerender } = render() openList() expect(screen.getByText(zh['section.live'])).toBeDefined() expect(screen.getByText(zh['section.settled'])).toBeDefined() // A live-only list keeps its own heading and drops the settled one. - rerender() + rerender() expect(screen.getByText(zh['section.live'])).toBeDefined() expect(screen.queryByText(zh['section.settled'])).toBeNull() }) it('shows a settled duration and ticks a live one', () => { vi.useFakeTimers({ now: 1_700_000_020_000 }) - render( { act(() => { vi.advanceTimersByTime(1_000) }) expect(screen.getByText('6秒')).toBeDefined() }) -}) -describe('ActivityListAction rows and observation', () => { it('lists rows with kind, label, and detail-or-status', () => { - render() + render() openList() const list = screen.getByRole('list', { name: zh['list.aria'] }) const items = within(list).getAllByRole('listitem') @@ -194,21 +138,52 @@ describe('ActivityListAction rows and observation', () => { expect(items[1]?.textContent).toContain('exit code: 3') }) + it('orders live rows first by start and settled rows newest-first with tie-breaks', () => { + const settled = (id: string, label: string, startedAt: number, finishedAt?: number): SessionJob => + job({ + id: id as SessionJob['id'], + label, + status: 'completed', + startedAt, + ...finishedAt !== undefined ? { finishedAt } : {}, + }) + render() + openList() + const labels = within(screen.getByRole('list', { name: zh['list.aria'] })) + .getAllByRole('listitem') + .map(item => item.querySelector('[title]')?.getAttribute('title')) + expect(labels).toEqual([ + 'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e', + ]) + expect(screen.getAllByText(zh['status.completed']).length).toBeGreaterThan(0) + }) +}) + +describe('JobListAction observation', () => { it('marks killed rows, renders every output line unfolded, and copies the command', async () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n') - const view: ObservedActivity = { - activityId: 'bash-1' as ActivityId, + const view: ObservedJob = { + jobId: 'bash-1' as SessionJob['id'], text: lines, gapBefore: false, status: 'killed', streaming: false, } - render() + render() openList() expect(screen.getByText(zh['status.killed'])).toBeDefined() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) @@ -223,45 +198,13 @@ describe('ActivityListAction rows and observation', () => { expect(writeText).toHaveBeenCalledWith('pnpm run build') }) - it('orders live rows first by start and settled rows newest-first with tie-breaks', () => { - const settled = (id: string, label: string, startedAt: number, finishedAt?: number): ActivityRow => - row({ - id: id as ActivityId, - label, - status: 'completed', - startedAt, - ...finishedAt !== undefined ? { finishedAt } : {}, - }) - render() - openList() - const labels = within(screen.getByRole('list', { name: zh['list.aria'] })) - .getAllByRole('listitem') - .map(item => item.querySelector('[title]')?.getAttribute('title')) - expect(labels).toEqual([ - 'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e', - ]) - expect(screen.getAllByText(zh['status.completed']).length).toBeGreaterThan(0) - }) - it('shifts an overflowing popover back inside the viewport and follows resizes', () => { vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440) vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 344 } as DOMRect) const originalWidth = window.innerWidth Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) try { - render() + render() openList() const menu = screen.getByRole('list', { name: zh['list.aria'] }) // 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin. @@ -282,7 +225,7 @@ describe('ActivityListAction rows and observation', () => { const originalWidth = window.innerWidth Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) try { - render() + render() openList() const menu = screen.getByRole('list', { name: zh['list.aria'] }) // max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin. @@ -294,39 +237,34 @@ describe('ActivityListAction rows and observation', () => { it('ignores other keys while open and closes once the roster empties', () => { const settledPair = [ - row({ id: 'bash-1' as ActivityId, status: 'completed', finishedAt: 2 }), - row({ id: 'bash-2' as ActivityId, status: 'completed', finishedAt: 3 }), + recordJob({ id: 'bash-1' as SessionJob['id'], status: 'completed', finishedAt: 2 }), + recordJob({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 3 }), ] - const { rerender } = render() - expect(screen.getByRole('button', { name: '2 个任务' })).toBeDefined() + const { rerender } = render() + expect(screen.getByRole('button', { name: '2 个后台任务' })).toBeDefined() openList() const list = screen.getByRole('list', { name: zh['list.aria'] }) fireEvent.keyDown(list, { key: 'a' }) expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() // The roster emptying unmounts the control entirely; the open flag resets first. - rerender() + rerender() expect(screen.queryByRole('button')).toBeNull() }) - it('starts observing on expand, renders the live text, and stops on collapse', () => { + it('starts observing on expand with the session identity, renders the live text, and stops on collapse', () => { const stop = vi.fn() const observe = vi.fn(() => stop) - const view: ObservedActivity = { - activityId: 'bash-1' as ActivityId, + const view: ObservedJob = { + jobId: 'bash-1' as SessionJob['id'], text: 'compiling…\n', gapBefore: false, status: 'running', streaming: true, } - render() + render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - expect(observe).toHaveBeenCalledWith('bash-1') + expect(observe).toHaveBeenCalledWith(SESSION, 'bash-1') expect(screen.getByText('compiling…')).toBeDefined() fireEvent.click(screen.getByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'pnpm run build') })) @@ -334,18 +272,15 @@ describe('ActivityListAction rows and observation', () => { }) it('surfaces retention gaps and stream failures above the panel', () => { - const view: ObservedActivity = { - activityId: 'bash-1' as ActivityId, + const view: ObservedJob = { + jobId: 'bash-1' as SessionJob['id'], text: 'tail only', gapBefore: true, status: 'running', streaming: false, error: 'connection lost', } - render() + render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) expect(screen.getByText(zh['output.gap'])).toBeDefined() @@ -354,9 +289,7 @@ describe('ActivityListAction rows and observation', () => { it('stops observation when the popover closes via Escape', () => { const stop = vi.fn() - render( stop)} />) + render( stop)} />) openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) fireEvent.keyDown(screen.getByRole('list', { name: zh['list.aria'] }), { key: 'Escape' }) @@ -368,13 +301,10 @@ describe('ActivityListAction rows and observation', () => { const stop = vi.fn() // One stable observe identity across renders, as the inject face provides. const observe = () => stop - const first = props({ rowsBySession: { [SESSION]: [row()] } }, observe) - const { rerender } = render() + const { rerender } = render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - rerender() + rerender() expect(stop).toHaveBeenCalledTimes(1) expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull() }) diff --git a/packages/client/ui-activity/tsconfig.json b/packages/client/ui-jobs/tsconfig.json similarity index 89% rename from packages/client/ui-activity/tsconfig.json rename to packages/client/ui-jobs/tsconfig.json index 562e2c2228..4b8aae7886 100644 --- a/packages/client/ui-activity/tsconfig.json +++ b/packages/client/ui-jobs/tsconfig.json @@ -10,7 +10,6 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, - { "path": "../../api/activity-controller/tsconfig.client.json" }, { "path": "../../api/session-controller/tsconfig.client.json" }, { "path": "../locale" }, { "path": "../ui-conversation" }, diff --git a/packages/client/ui-activity/tsdown.config.ts b/packages/client/ui-jobs/tsdown.config.ts similarity index 76% rename from packages/client/ui-activity/tsdown.config.ts rename to packages/client/ui-jobs/tsdown.config.ts index 096771fe90..c2f3a27162 100644 --- a/packages/client/ui-activity/tsdown.config.ts +++ b/packages/client/ui-jobs/tsdown.config.ts @@ -1,6 +1,6 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-ui-activity', + '@deepseek-ai/dsh-client-ui-jobs', ['lib/types/index.js', 'lib/types/invariant.js'], ) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 066e930d6d..23b36a8440 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1144,8 +1144,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ - 'client-ui-activity ActivityListAction id \'activity-list\'', 'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'', + 'client-ui-jobs JobListAction id \'job-list\'', 'client-ui-schedule ScheduleCatalogAction id \'schedule-catalog\'', 'client-ui-subagent SubagentCatalogAction id \'subagent-catalog\'', 'experimental-client-ui-agent-team TeamAction id \'agent-team\'', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 21e7f10221..80cc02f219 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -81,68 +81,6 @@ export interface TypeApiEntry { /** Every harness `ctx.` service, sorted by key. */ export const SERVICE_API: readonly ServiceApiEntry[] = [ - { - key: 'activities', - summary: 'Abstract streaming-output registry.', - description: 'Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected.\n- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource.\n- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap.\n- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it. Every listener is contained.', - methods: [ - { - signature: 'abstract open(spec: ActivityOpen): ActivityHandle', - description: 'Validate the spec, attach owner cleanup, and atomically register one activity. A rejection leaves no id; after return the producer owns the handle and the visible set has changed.', - parameters: [{ name: 'spec', description: 'activity identity, owner, and correlation.' }], - returns: 'the producer face of the registered activity.', - }, - { - signature: 'abstract list(caller?: Agent): ActivitySnapshot[]', - description: 'List caller-owned and unowned activities in registration order without exposing another session\'s labels or output.', - parameters: [{ name: 'caller', description: 'reading agent; a non-agent caller sees only unowned activities.' }], - returns: 'fresh snapshots.', - }, - { - signature: 'abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot', - description: 'Return a fresh snapshot. Throws for an unknown or foreign activity.', - parameters: [{ name: 'id', description: 'activity to look up.' }, { name: 'caller', description: 'reading agent checked against the owner.' }], - returns: 'a fresh snapshot.', - }, - { - signature: 'abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead', - description: 'Read retained output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Throws for an unknown or foreign activity or a negative or non-integer offset.', - parameters: [{ name: 'id', description: 'activity to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }], - returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.', - }, - { - signature: 'abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void', - description: 'Register an effect-scoped observer of visible-set changes: opening, detail updates, settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas. Listeners are contained and never awaited.', - parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned activity changed and every caller\'s set did.' }], - returns: 'disposer that unregisters the listener.', - }, - { - signature: 'abstract onOutput(listener: ActivityOutputListener): () => void', - description: 'Register an effect-scoped observer of stream advancement — one signal per committed append and one at settlement, carrying only the activity id. A consumer schedules a read from its own cursor; the registry never pushes payloads. Listeners are contained and never awaited.', - parameters: [{ name: 'listener', description: 'receives the id whose stream advanced.' }], - returns: 'disposer that unregisters the listener.', - }, - ], - }, - { - key: 'activityController', - summary: 'Host service backing the generated `ctx.remote.activity` namespace.', - description: 'Host service backing the generated `ctx.remote.activity` namespace.', - methods: [ - { - signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable', - description: 'Stream a complete roster baseline followed by whole-bucket replacements.', - parameters: [{ name: 'signal', description: 'generation cancellation.' }], - returns: 'baseline followed by per-owner roster replacement frames.', - }, - { - signature: '@Remote({ mode: \'stream\' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable', - description: 'Stream one activity\'s retained output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursors never observe these reads.', - parameters: [{ name: 'request', description: 'target activity and optional resume offset.' }, { name: 'signal', description: 'generation cancellation.' }], - returns: 'anchor, coalesced output frames, and the terminal status.', - }, - ], - }, { key: 'agentDefaultModel', summary: 'Owns the default model selection independently of any Host or transport.', @@ -1098,7 +1036,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'jobs', summary: 'Abstract background job registry.', - description: 'Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.', + description: 'Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected.\n- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle.', methods: [ { signature: 'abstract start(spec: JobStart): JobId', @@ -1148,6 +1086,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned job changed and every caller\'s set did.' }], returns: 'disposer that unregisters the listener.', }, + { + signature: 'abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead', + description: 'Read retained record output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Never marks the job reported. Throws for an unknown or foreign job, a job without a JobStart.record declaration, or a negative or non-integer offset.', + parameters: [{ name: 'id', description: 'job to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }], + returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.', + }, + { + signature: 'abstract onOutput(listener: JobOutputListener): () => void', + description: 'Register an effect-scoped observer of record advancement — one signal per committed append and one at settlement, carrying only the job id. A consumer schedules a readRecord from its own cursor; the registry never pushes payloads. Delivery is owner-relative on the same terms as onJobsChanged. Listeners are contained and never awaited.', + parameters: [{ name: 'listener', description: 'receives the id whose record advanced.' }], + returns: 'disposer that unregisters the listener.', + }, { signature: 'abstract attachController(name: string): () => void', description: 'Attach an effect-scoped controller that can read and stop jobs. It serves the owners its registering context\'s scope covers, and start refuses an owner no attached controller serves.', @@ -1505,6 +1455,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'one complete baseline followed by live replacement frames.', }, + { + signature: '@Remote({ mode: \'stream\' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable', + description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.', + parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], + returns: 'anchor, coalesced output frames, and the terminal status.', + }, ], }, { @@ -3473,94 +3429,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the Service and Event signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ - { - name: 'ActivitiesChangedListener', - declaration: 'export type ActivitiesChangedListener = (owner: Agent | undefined) => void;', - }, - { - name: 'ActivityAppendOptions', - declaration: 'export interface ActivityAppendOptions {\n channel?: ActivityChannel;\n gapBefore?: true;\n}', - }, - { - name: 'ActivityChannel', - declaration: 'export type ActivityChannel = \'stdout\' | \'stderr\';', - }, - { - name: 'ActivityControlFrame', - declaration: 'export type ActivityControlFrame = {\n readonly type: \'baseline\';\n readonly activities: readonly ActivityRow[];\n} | {\n readonly type: \'rows\';\n readonly sessionId?: SessionId;\n readonly activities: readonly ActivityRow[];\n};', - }, - { - name: 'ActivityCorrelation', - declaration: 'export interface ActivityCorrelation {\n callId?: ToolCallId;\n jobId?: JobId;\n}', - }, - { - name: 'ActivityHandle', - declaration: 'export interface ActivityHandle {\n readonly id: ActivityId;\n append(text: string, options?: ActivityAppendOptions): void;\n updateDetail(detail: string): void;\n end(outcome: ActivityOutcome): void;\n}', - }, - { - name: 'ActivityId', - declaration: 'export type ActivityId = Branded<\'ActivityId\'>;', - }, - { - name: 'ActivityKind', - declaration: 'export type ActivityKind = ActivityKindMap[keyof ActivityKindMap];', - }, - { - name: 'ActivityKindMap', - declaration: 'export interface ActivityKindMap {\n bash: \'bash\';\n}', - }, - { - name: 'ActivityObserveFrame', - declaration: 'export type ActivityObserveFrame = {\n readonly type: \'opened\';\n readonly activityId: ActivityId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly ActivityWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};', - }, - { - name: 'ActivityObserveRequest', - declaration: 'export interface ActivityObserveRequest {\n readonly activityId: ActivityId;\n readonly from?: number;\n}', - }, - { - name: 'ActivityOpen', - declaration: 'export interface ActivityOpen {\n kind: ActivityKind;\n label: string;\n owner?: Agent;\n correlation?: ActivityCorrelation;\n}', - }, - { - name: 'ActivityOutcome', - declaration: 'export interface ActivityOutcome {\n status: \'completed\' | \'failed\' | \'killed\';\n detail?: string;\n}', - }, - { - name: 'ActivityOutputChunk', - declaration: 'export interface ActivityOutputChunk {\n at: number;\n text: string;\n channel?: ActivityChannel;\n gapBefore?: true;\n}', - }, - { - name: 'ActivityOutputListener', - declaration: 'export type ActivityOutputListener = (id: ActivityId) => void;', - }, - { - name: 'ActivityRead', - declaration: 'export interface ActivityRead {\n chunks: readonly ActivityOutputChunk[];\n next: number;\n lossy: boolean;\n}', - }, - { - name: 'ActivityRow', - declaration: 'export interface ActivityRow {\n readonly id: ActivityId;\n readonly kind: string;\n readonly label: string;\n readonly sessionId?: SessionId;\n readonly correlation?: ActivityRowCorrelation;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal: number;\n}', - }, - { - name: 'ActivityRowCorrelation', - declaration: 'export interface ActivityRowCorrelation {\n readonly callId?: ToolCallId;\n readonly jobId?: JobId;\n}', - }, - { - name: 'ActivityRowStatus', - declaration: 'export type ActivityRowStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';', - }, - { - name: 'ActivitySnapshot', - declaration: 'export interface ActivitySnapshot {\n id: ActivityId;\n kind: ActivityKind;\n label: string;\n ownerSession?: SessionId;\n correlation?: ActivityCorrelation;\n status: ActivityStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n outputTotal: number;\n outputEarliest: number;\n}', - }, - { - name: 'ActivityStatus', - declaration: 'export type ActivityStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';', - }, - { - name: 'ActivityWireChunk', - declaration: 'export interface ActivityWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', - }, { name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', @@ -4349,6 +4217,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InvokeRemoteRequest', declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly signal?: AbortSignal;\n}', }, + { + name: 'JobAppendOptions', + declaration: 'export interface JobAppendOptions {\n channel?: JobChannel;\n gapBefore?: true;\n}', + }, + { + name: 'JobChannel', + declaration: 'export type JobChannel = \'stdout\' | \'stderr\';', + }, { name: 'JobDoneListener', declaration: 'export type JobDoneListener = (snapshot: JobSnapshot, owner: Agent | undefined) => void | PromiseLike;', @@ -4373,21 +4249,33 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JobOutcome', declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}', }, + { + name: 'JobOutputListener', + declaration: 'export type JobOutputListener = (id: JobId) => void;', + }, { name: 'JobRead', declaration: 'export interface JobRead {\n text: string;\n snapshot: JobSnapshot;\n}', }, + { + name: 'JobRecordChunk', + declaration: 'export interface JobRecordChunk {\n at: number;\n text: string;\n channel?: JobChannel;\n gapBefore?: true;\n}', + }, + { + name: 'JobRecordRead', + declaration: 'export interface JobRecordRead {\n chunks: readonly JobRecordChunk[];\n next: number;\n lossy: boolean;\n}', + }, { name: 'JobsChangedListener', declaration: 'export type JobsChangedListener = (owner: Agent | undefined) => void;', }, { name: 'JobSnapshot', - declaration: 'export interface JobSnapshot {\n id: JobId;\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: JobStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}', + declaration: 'export interface JobSnapshot {\n id: JobId;\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: JobStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n outputTotal?: number;\n outputEarliest?: number;\n}', }, { name: 'JobStart', - declaration: 'export interface JobStart {\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n run(): JobHooks;\n}', + declaration: 'export interface JobStart {\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n record?: true;\n run(job: RunningJob): JobHooks;\n}', }, { name: 'JobStatus', @@ -4877,6 +4765,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RunnerFailureRule', declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}', }, + { + name: 'RunningJob', + declaration: 'export interface RunningJob {\n readonly id: JobId;\n append(text: string, options?: JobAppendOptions): void;\n updateDetail(detail: string): void;\n}', + }, { name: 'SandboxEnforcement', declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';', @@ -5103,7 +4995,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionJob', - declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n}', + declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal?: number;\n}', + }, + { + name: 'SessionJobWireChunk', + declaration: 'export interface SessionJobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', }, { name: 'SessionLineageNode', @@ -5137,6 +5033,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionObservationOptions', declaration: 'export interface SessionObservationOptions {\n readonly signal?: AbortSignal;\n readonly projectionMode?: \'all\' | \'none\';\n}', }, + { + name: 'SessionObserveJobFrame', + declaration: 'export type SessionObserveJobFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly SessionJobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n readonly finishedAt?: number;\n};', + }, + { + name: 'SessionObserveJobRequest', + declaration: 'export interface SessionObserveJobRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}', + }, { name: 'SessionOpenWorkspacePathRequest', declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly path: string;\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da5b3091d6..d938573ee5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -625,110 +625,6 @@ importers: specifier: workspace:^ version: link:../../interaction/user-approval - packages/activity/activity: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - - packages/activity/activity-local: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-include': - specifier: workspace:^ - version: link:../../../vendor/include - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-activity': - specifier: workspace:^ - version: link:../activity - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - - packages/api/activity-controller: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - zod: - specifier: ^4.4.3 - version: 4.4.3 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-activity': - specifier: workspace:^ - version: link:../../activity/activity - '@deepseek-ai/dsh-activity-local': - specifier: workspace:^ - version: link:../../activity/activity-local - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-api-gateway': - specifier: workspace:^ - version: link:../gateway - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../../client/connection - '@deepseek-ai/dsh-client-store': - specifier: workspace:^ - version: link:../../client/store - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol - packages/api/gateway: dependencies: '@deepseek-ai/dsh-deque': @@ -793,9 +689,6 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets - '@deepseek-ai/dsh-api-activity-controller': - specifier: workspace:^ - version: link:../activity-controller '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway @@ -1174,9 +1067,6 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer - '@deepseek-ai/dsh-activity-local': - specifier: workspace:^ - version: link:../../activity/activity-local '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1571,9 +1461,6 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets - '@deepseek-ai/dsh-api-activity-controller': - specifier: workspace:^ - version: link:../../api/activity-controller '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -1601,9 +1488,6 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules - '@deepseek-ai/dsh-client-ui-activity': - specifier: workspace:^ - version: link:../../client/ui-activity '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../client/ui-agent-preset @@ -1643,6 +1527,9 @@ importers: '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../../client/ui-input-trigger + '@deepseek-ai/dsh-client-ui-jobs': + specifier: workspace:^ + version: link:../../client/ui-jobs '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../client/ui-layout @@ -1961,48 +1848,6 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/client/ui-activity: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-activity-controller': - specifier: workspace:^ - version: link:../../api/activity-controller - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:^ - version: link:../../api/session-controller - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-renderer': - specifier: workspace:^ - version: link:../ui-renderer - '@deepseek-ai/dsh-client-ui-session': - specifier: workspace:^ - version: link:../ui-session - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-agent-preset: devDependencies: '@deepseek-ai/cordis': @@ -2723,6 +2568,48 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-jobs: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-layout: devDependencies: '@deepseek-ai/cordis': @@ -8288,9 +8175,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-activity': - specifier: workspace:^ - version: link:../../activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -8416,9 +8300,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-activity': - specifier: workspace:^ - version: link:../../activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -10405,12 +10286,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-activity': - specifier: workspace:^ - version: link:../../activity/activity - '@deepseek-ai/dsh-activity-local': - specifier: workspace:^ - version: link:../../activity/activity-local '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -10575,9 +10450,6 @@ importers: '@deepseek-ai/dsh-acp': specifier: workspace:^ version: link:../../packages/acp/acp - '@deepseek-ai/dsh-activity': - specifier: workspace:^ - version: link:../../packages/activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 2ae2320a08..f735144468 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -13,7 +13,6 @@ "@deepseek-ai/cosmokit": "workspace:^", "@deepseek-ai/dsh": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", - "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 02eccc59d1..d477494d34 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -109,8 +109,6 @@ export const SERVICE_PAGE: Record = { subprocess: 'subprocess.md', systemPrompt: 'system-prompt.md', jobs: 'jobs.md', - activities: 'activity.md', - activityController: 'activity.md', sessionTelemetry: 'session-telemetry.md', agentTeams: 'agent-team.md', tokenMeter: 'token-meter.md', @@ -175,7 +173,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API', workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API', - activityFeed: 'client-side interface-typed browser service — packages/api/activity-controller/README.md owns the API', + jobOutput: 'client-side interface-typed browser service — packages/api/session-controller/README.md owns the API', } /** @@ -322,6 +320,8 @@ export const LINK_MAP: Readonly> = { SessionId: 'core.md', SessionListRequest: 'session.md', SessionListValue: 'session.md', + SessionObserveJobFrame: 'session.md', + SessionObserveJobRequest: 'session.md', ModelCatalog: 'session.md', SessionOpenWorkspacePathRequest: 'session.md', SessionOpenWorkspacePathValue: 'session.md', @@ -501,22 +501,18 @@ export const LINK_MAP: Readonly> = { PromptSectionOrderName: 'system-prompt.md', SystemPrompt: 'system-prompt.md', ToolProviderResult: 'system-prompt.md', - ActivitiesChangedListener: 'activity.md', - ActivityHandle: 'activity.md', - ActivityId: 'activity.md', - ActivityOpen: 'activity.md', - ActivityOutputListener: 'activity.md', - ActivityRead: 'activity.md', - ActivitySnapshot: 'activity.md', - ActivityControlFrame: 'activity.md', - ActivityObserveFrame: 'activity.md', - ActivityObserveRequest: 'activity.md', JobDoneListener: 'jobs.md', JobId: 'jobs.md', JobRead: 'jobs.md', JobSnapshot: 'jobs.md', JobStart: 'jobs.md', JobsChangedListener: 'jobs.md', + JobAppendOptions: 'jobs.md', + JobChannel: 'jobs.md', + JobOutputListener: 'jobs.md', + JobRecordChunk: 'jobs.md', + JobRecordRead: 'jobs.md', + RunningJob: 'jobs.md', CreateTeamTaskRequest: 'agent-team.md', SendTeamMessageRequest: 'agent-team.md', SendTeamMessageResult: 'agent-team.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 008991a00c..3638b0c002 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -198,13 +198,6 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on.', }, - { - key: 'activityController', - pkg: 'api-activity-controller', - title: 'Host Activity Remote controller', - mode: 'core', - note: 'Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace.', - }, { key: 'invariants', pkg: 'invariants', @@ -583,17 +576,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Background job registry', mode: 'seam', implementations: ['jobs-local'], - consumers: ['tool-bash', 'tool-terminal', 'tool-subagent', 'tool-jobs'], - note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.', - }, - { - key: 'activities', - pkg: 'activity', - title: 'Live activity observation registry', - mode: 'seam', - implementations: ['activity-local'], - consumers: ['tool-bash', 'tool-pwsh', 'tool-workflow', 'activity-controller'], - note: 'Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched.', + consumers: ['tool-bash', 'tool-pwsh', 'tool-terminal', 'tool-subagent', 'tool-jobs'], + note: 'Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.', }, { key: 'web', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 15b481383c..63054fd183 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -5,7 +5,6 @@ const CLIENT_FACE_INCLUDE: readonly string[] = [] /** Packages exempted from automatic Client/Host treatment despite declaring `dsh.client`. */ const CLIENT_FACE_EXCLUDE: readonly string[] = [ - '@deepseek-ai/dsh-api-activity-controller', '@deepseek-ai/dsh-api-session-controller', '@deepseek-ai/dsh-api-workspace-controller', ] diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 26263fe018..4294cff84b 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -123,7 +123,6 @@ function hostRuntimeFixture(): { describe('package dependency scope', () => { it('keeps the measured Host relay roster explicit', () => { expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([ - '@deepseek-ai/dsh-api-activity-controller', '@deepseek-ai/dsh-api-session-controller', '@deepseek-ai/dsh-api-workspace-controller', ]) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index ec942708ea..a563dc46bd 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -46,10 +46,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, - 'packages/activity/activity': { kind: 'none', reason: 'Pure observation seam for humans; activity output becomes model-visible only through producers\' existing tool results and jobs reads.' }, - 'packages/activity/activity-local': { kind: 'none', reason: 'In-memory observation provider; producers own every model-visible surface.' }, - 'packages/api/activity-controller': { kind: 'none', reason: 'Browser/Host observation transport; registers no prompt, tool, or session event.' }, - 'packages/client/ui-activity': { kind: 'none', reason: 'Browser-side presentation of host-observed output; touches no prompt, message, schema, stream, or tool result.' }, + 'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side presentation of host-observed output; touches no prompt, message, schema, stream, or tool result.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, 'packages/shell/shell-env': { kind: 'indirect', reason: 'The env service exposes managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' }, 'packages/shell/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index c09254eec2..d8ba3b6830 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,13 +47,6 @@ "@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"], "@deepseek-ai/dsh-api-gateway/invariant": ["./packages/api/gateway/src/invariant.ts"], "@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"], - "@deepseek-ai/dsh-api-activity-controller": ["./packages/api/activity-controller/src/index.ts"], - "@deepseek-ai/dsh-api-activity-controller/client": ["./packages/api/activity-controller/src/client/index.ts"], - "@deepseek-ai/dsh-api-activity-controller/invariant": ["./packages/api/activity-controller/src/invariant.ts"], - "@deepseek-ai/dsh-api-activity-controller/types": ["./packages/api/activity-controller/src/types.ts"], - "@deepseek-ai/dsh-client-ui-activity": ["./packages/client/ui-activity/src"], - "@deepseek-ai/dsh-client-ui-activity/client": ["./packages/client/ui-activity/src/client/index.ts"], - "@deepseek-ai/dsh-client-ui-activity/invariant": ["./packages/client/ui-activity/src/invariant.ts"], "@deepseek-ai/dsh-api-session-controller": ["./packages/api/session-controller/src/index.ts"], "@deepseek-ai/dsh-api-session-controller/client": ["./packages/api/session-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-session-controller/invariant": ["./packages/api/session-controller/src/invariant.ts"], @@ -108,7 +101,6 @@ "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"], - "@deepseek-ai/dsh-activity/brand": ["./packages/activity/activity/src/brand.ts"], "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"], "@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"], "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"], @@ -212,6 +204,8 @@ "@deepseek-ai/dsh-experimental-client-ui-agent-team": ["./packages/experimental/client-ui-agent-team/src"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], "@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"], + "@deepseek-ai/dsh-client-ui-jobs/client": ["./packages/client/ui-jobs/src/client/index.ts"], + "@deepseek-ai/dsh-client-ui-jobs/invariant": ["./packages/client/ui-jobs/src/invariant.ts"], "@deepseek-ai/dsh-client-ui-schedule": ["./packages/client/ui-schedule/src"], "@deepseek-ai/dsh-client-ui-schedule/client": ["./packages/client/ui-schedule/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-schedule/invariant": ["./packages/client/ui-schedule/src/invariant.ts"], @@ -255,10 +249,6 @@ "@deepseek-ai/dsh-acp/invariant": ["./packages/acp/acp/src/invariant.ts"], "@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"], "@deepseek-ai/dsh-acp-app/invariant": ["./packages/bundle/acp-app/src/invariant.ts"], - "@deepseek-ai/dsh-activity": ["./packages/activity/activity/src"], - "@deepseek-ai/dsh-activity/invariant": ["./packages/activity/activity/src/invariant.ts"], - "@deepseek-ai/dsh-activity-local": ["./packages/activity/activity-local/src"], - "@deepseek-ai/dsh-activity-local/invariant": ["./packages/activity/activity-local/src/invariant.ts"], "@deepseek-ai/dsh-agent": ["./packages/core/agent/src"], "@deepseek-ai/dsh-agent-default-model": ["./packages/core/agent-default-model/src"], "@deepseek-ai/dsh-agent-default-model/invariant": ["./packages/core/agent-default-model/src/invariant.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 2426473861..bb6735bbc7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -57,7 +57,6 @@ { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, - { "path": "./packages/api/activity-controller/tsconfig.client.json" }, { "path": "./packages/api/workspace-controller/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/extensions/cordis-client-runner" }, @@ -78,7 +77,7 @@ { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, - { "path": "./packages/client/ui-activity" }, + { "path": "./packages/client/ui-jobs" }, { "path": "./packages/experimental/client-ui-agent-team" }, { "path": "./packages/client/ui-schedule" }, { "path": "./packages/client/ui-directory-picker-browse" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index b9fac3cdff..4bbbda019b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -81,7 +81,7 @@ "apps/web/tests/subagent-interrupt-ui.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", "apps/web/tests/background-job-list.e2e.ts", - "apps/web/tests/live-activity-stream.e2e.ts", + "apps/web/tests/live-job-stream.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", "apps/web/tests/turn-tail-actions.e2e.ts", @@ -152,7 +152,6 @@ { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/api/session-controller/tsconfig.host.json" }, - { "path": "./packages/api/activity-controller/tsconfig.host.json" }, { "path": "./packages/api/settings-controller" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, @@ -298,8 +297,6 @@ { "path": "./packages/jobs/jobs" }, { "path": "./packages/jobs/jobs-local" }, { "path": "./packages/jobs/tool-jobs" }, - { "path": "./packages/activity/activity" }, - { "path": "./packages/activity/activity-local" }, { "path": "./packages/experimental/agent-team" }, { "path": "./packages/experimental/agent-team-profile" }, { "path": "./packages/experimental/tool-agent-team" }, From 3c79e71cdc467ab651efd60fad7a94db11da6cfc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 18:34:36 +0800 Subject: [PATCH 017/141] docs(i18n): mirror the record refactor across the bilingual pairs Chinese sides of the jobs, jobs-local, tool-bash, tool-pwsh, tool-workflow, session-controller, and ui-jobs pages follow their English edits; the generated en-only catalogs (config catalog, capability seams, event producer/consumer) get the same section surgery by hand; the activity subsystem page triplet is fully removed and the jobs page carries the record contract in both languages with fresh type-equiv manifest entries. The three prior activity notes point at the superseding note. Web e2e label assertions follow the ui-jobs copy, and the three test call sites that invoked run() bare pass the producer face. --- ...9-01-jobs-absorb-activity-record.i18n.yaml | 6 + .../2026-09-01-jobs-absorb-activity-record.md | 2 +- ...26-09-01-jobs-absorb-activity-record.zh.md | 2 +- ...08-08-web-background-job-display.i18n.yaml | 4 +- ...-08-24-activity-observation-seam.i18n.yaml | 4 +- .../2026-08-25-unified-task-list.i18n.yaml | 4 +- apps/web/tests/background-job-list.e2e.ts | 6 +- docs/architecture.i18n.yaml | 4 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.zh.md | 20 +-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.zh.md | 61 +++------- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.zh.md | 14 +-- docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/activity.i18n.yaml | 6 - docs/subsystems/jobs.i18n.yaml | 4 +- docs/subsystems/jobs.zh.md | 114 ++++++++++++++++-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.zh.md | 4 + packages/client/README.i18n.yaml | 4 +- packages/jobs/jobs-local/README.i18n.yaml | 4 +- packages/jobs/jobs-local/README.zh.md | 11 +- packages/jobs/jobs-local/tests/jobs.spec.ts | 6 +- packages/jobs/jobs/README.i18n.yaml | 4 +- packages/jobs/jobs/README.zh.md | 13 +- packages/shell/tool-bash/README.i18n.yaml | 4 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-pwsh/README.i18n.yaml | 4 +- packages/shell/tool-pwsh/README.zh.md | 2 +- .../workflow/tool-workflow/README.i18n.yaml | 4 +- packages/workflow/tool-workflow/README.zh.md | 3 +- scripts/type-equiv.manifest.json | 15 +++ 33 files changed, 214 insertions(+), 137 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml delete mode 100644 docs/subsystems/activity.i18n.yaml diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml new file mode 100644 index 0000000000..87e45ad40a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +2026-09-01-jobs-absorb-activity-record.md: cc2cc8606a50e2d7c06e183a09df709b75b9bd35 +2026-09-01-jobs-absorb-activity-record.zh.md: 8b6739192b56899899773ba1e7af2bf1bd846b0b diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md index 501cdcf801..cc2cc8606a 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -22,7 +22,7 @@ The one activity without a job was the foreground workflow mirror. Everything el - **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `outputTotal` is present. The two-roster join is deleted. - **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows. -## Rejected +## Alternatives considered - **A `foreground: true` job mode** to keep the foreground workflow panel: it needs two coupled enforcement points (reported-at-birth and a `job_list` filter) whose divergence double-delivers or leaks rows to the model, for one edge feature replaceable by `run_in_background`. - **Serving model reads from the record** (deleting `readOutput`): model reads are producer-formatted (truncation and spill notices, sandbox markers) and consuming; the record is raw, channel-labeled, and non-consuming. Unifying them moves producer-specific formatting into the registry or noise into the observer stream. diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md index 40e795146c..8b6739192b 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -22,7 +22,7 @@ Status: implemented - **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `outputTotal` 存在时可展开。双名册 join 被删除。 - **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。 -## 已否决 +## 曾考虑的替代方案 - **`foreground: true` job 模式**以保留前台 workflow 面板:它需要两个耦合的执行点(出生即 reported 与 `job_list` 过滤),两者一旦分叉就会双投递或向模型泄漏行,而它换来的只是一个可被 `run_in_background` 替代的边缘特性。 - **用 record 服务模型读取**(删除 `readOutput`):模型读取是生产者格式化的(截断与 spill 提示、sandbox 标记)且消耗型;record 是原始、带 channel 标签、非消耗的。统一二者要么把生产者特有格式化搬进注册表,要么把噪声混入观察者流。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 9b64d73885..444bbac0ad 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 23074b5d38cdab8c45925c75a7d0b851f7be2ece -2026-08-08-web-background-job-display.zh.md: 06645267ee8afadf284b7654b0cd4a672fe7e4b4 +2026-08-08-web-background-job-display.md: 3dd003c0e972ec7a5857e84980520eee93b53955 +2026-08-08-web-background-job-display.zh.md: 5d1ee4bada9c1966e4329d35f5a4db64300ca3b4 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml index 96434aa147..651ef99e3c 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md -2026-08-24-activity-observation-seam.md: 88109cb3404132885bde359af0907a29ea1bee2c -2026-08-24-activity-observation-seam.zh.md: b8aa938edfd0019794211975c616aa50381ffe77 +2026-08-24-activity-observation-seam.md: 29ee08b1bb7f72a63d2f84278af62587060de3fe +2026-08-24-activity-observation-seam.zh.md: 97ac4f195c83cdd4f16d2f0a108abd2b3338d95d diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml index cba3c8b11c..eab6f6fa1b 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-unified-task-list.md -2026-08-25-unified-task-list.md: e67ca84d96424e723f6a5660cbb2b02733e42892 -2026-08-25-unified-task-list.zh.md: 2a71b04ec33b635478bd4897e8777abb3081a8df +2026-08-25-unified-task-list.md: daf6582bf2c9e4b4a5ce26d6ae632fb78b30dbcb +2026-08-25-unified-task-list.zh.md: 624747396e175a216047239333575322ec4b1808 diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 57fdd1358d..1030369886 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { it('shows a running background job in the session header without a refresh', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running')) // Polling for zero would pass at t=0 before delivery and prove nothing. - const trigger = page.getByRole('button', { name: '1 task running' }) + const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) const started = await scaffold.ctx.tools.execute({ @@ -97,7 +97,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { await trigger.waitFor({ timeout: 15_000 }) await trigger.click() - const row = page.getByRole('list', { name: 'Tasks' }).getByRole('listitem').first() + const row = page.getByRole('list', { name: 'Background jobs' }).getByRole('listitem').first() await row.waitFor({ timeout: 10_000 }) await expect.poll(() => row.textContent()).toContain(COMMAND) @@ -111,7 +111,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled')) expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') - const idle = page.getByRole('button', { name: '1 task' }) + const idle = page.getByRole('button', { name: '1 background job' }) await idle.waitFor({ timeout: 20_000 }) const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 25942087ae..1f40c3002f 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 8a2f9b772c6c50aed0389421fd26a713668b1907 -architecture.zh.md: cc291fb0184bcc62a283741ff453e62d7400eb0a +architecture.md: f36c956d9a14f6b4490d2cd5ce03d807da4a94ad +architecture.zh.md: 67e2ae905cdf0ddb210f320fd7b8cb34395d99d4 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 9327ad1685..5b19c660e0 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 6c0867eeb8d47fefcbbf270e79123935cdae2aa3 -capability-seams.zh.md: aa741f96303e0dbf5f883c2fb4a2af927e165f63 +capability-seams.md: db2b8db95647204b4a49a63c6a7f1d97b942e635 +capability-seams.zh.md: f145241760737654c39662c76ddd027cf22bc409 diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index aa741f9630..f145241760 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -47,8 +47,6 @@ flowchart LR pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
        Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
        Host directory-picking Remote controller"] - pkg_api_activity_controller["api-activity-controller"] - svc_activityController["ctx.activityController
        Host Activity Remote controller"] svc_invariants["ctx.invariants
        Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -188,11 +186,6 @@ flowchart LR svc_jobs["ctx.jobs
        Background job registry"] pkg_jobs_local["jobs-local"] pkg_tool_jobs["tool-jobs"] - pkg_activity["activity"] - svc_activities["ctx.activities
        Live activity observation registry"] - pkg_activity_local["activity-local"] - pkg_tool_workflow["tool-workflow"] - pkg_activity_controller["activity-controller"] pkg_web["web"] svc_web["ctx.web
        Web access provider registry"] pkg_web_search_exa["web-search-exa"] @@ -216,6 +209,7 @@ flowchart LR pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
        Workflow script engine"] pkg_workflow_worker_thread["workflow-worker-thread"] + pkg_tool_workflow["tool-workflow"] pkg_webhook["webhook"] svc_webhookRuntime["ctx.webhookRuntime
        Webhook rule runtime"] pkg_webhook_github["webhook-github"] @@ -225,13 +219,10 @@ flowchart LR pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
        Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
        Dynamic Cordis inspect registry"] - pkg_activity --> svc_activities - pkg_activity_local --> svc_activities pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets - pkg_api_activity_controller --> svc_activityController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences @@ -344,10 +335,6 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry - svc_activities --> pkg_activity_controller - svc_activities --> pkg_tool_bash - svc_activities --> pkg_tool_pwsh - svc_activities --> pkg_tool_workflow svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_agent_spine_demo @@ -384,6 +371,7 @@ flowchart LR svc_invariants --> pkg_session svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs + svc_jobs --> pkg_tool_pwsh svc_jobs --> pkg_tool_subagent svc_jobs --> pkg_tool_terminal svc_llm --> pkg_agent_loop @@ -491,7 +479,6 @@ flowchart LR | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | -| `ctx.activityController` | `core` | [`api-activity-controller`](../packages/api/activity-controller) | - | - | - | 通过生成的 Remote namespace 负责实时活动 roster 流与逐活动的非消费输出观察。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | @@ -537,8 +524,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | -| `ctx.activities` | `seam` | [`activity`](../packages/activity/activity) | [`activity-local`](../packages/activity/activity-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-workflow`](../packages/workflow/tool-workflow), `activity-controller` | - | 生产者把实时输出与状态镜像进非消费的偏移寻址流;activity-controller 把它们提供给浏览器,模型可见路径(jobs 游标、工具结果)保持不动。 | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 88653dc4ad..c71be1f3c9 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 424eff1c5e6e4ab96687e0688280cfc20cd31729 -config-catalog.zh.md: 9591737dbf8593edb594ccc3373ab8354a2201aa +config-catalog.md: 64b72e0ff774bd7ecd6142a1ee1f0663e28194b7 +config-catalog.zh.md: ab2c0aac8f341e5855299236787421f4b60d357a diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9591737dbf..ab2c0aac8f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -35,22 +35,6 @@ export interface AcpConfig { 来源:[`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts) - - -## `@deepseek-ai/dsh-activity-local` - -```ts config-catalog -/** Configuration for the process-local observation registry. */ -export interface Config { - /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ - retainBytes?: number - /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ - settledRetainBytes?: number -} -``` - -来源:[`packages/activity/activity-local/src/index.ts:31`](../packages/activity/activity-local/src/index.ts) - ## `@deepseek-ai/dsh-agent-default-model` @@ -294,24 +278,6 @@ export interface Config { 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) - - -## `@deepseek-ai/dsh-api-activity-controller` - -Requires: `typert` - -```ts config-catalog -/** Configuration for the Activity Remote owner. */ -export interface Config { - /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ - flushMs?: number - /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ - maxFrameBytes?: number -} -``` - -来源:[`packages/api/activity-controller/src/index.ts:26`](../packages/api/activity-controller/src/index.ts) - ## `@deepseek-ai/dsh-api-gateway` @@ -341,10 +307,14 @@ export interface Config { readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean + /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number } ``` -来源:[`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) @@ -990,10 +960,14 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number + /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number } ``` -来源:[`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) +来源:[`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts) @@ -2714,12 +2688,12 @@ export type TokenMeterConfig = Record export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ - activityPollMs?: number + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } ``` -来源:[`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts) +来源:[`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) @@ -2881,12 +2855,12 @@ export interface Config { export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ - activityPollMs?: number + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } ``` -来源:[`packages/shell/tool-pwsh/src/index.ts:51`](../packages/shell/tool-pwsh/src/index.ts) +来源:[`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts) @@ -3456,7 +3430,6 @@ export interface Config { - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-activity`([`packages/client/ui-activity/src/index.ts`](../packages/client/ui-activity/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval`([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) @@ -3470,6 +3443,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-directory-picker-native`([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-input-trigger`([`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-jobs`([`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout`([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-message-feedback`([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model-selection`([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) @@ -3530,7 +3504,6 @@ export interface Config { 抽象服务类——部署时应改为加载具体的实现包(参见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md))。 -- `@deepseek-ai/dsh-activity` — 抽象 `ActivityRegistry`([`packages/activity/activity/src/index.ts`](../packages/activity/activity/src/index.ts)) - `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — 抽象 `CodeRuntime`([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 7ff1ac079f..7ef2ec250e 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 48aacf9dc6ece2431402ab72c171d236ed3b1359 -event-producer-consumer.zh.md: f644336b2cf540e28a88e2a6729508586021d46f +event-producer-consumer.md: fc05e2ec6ba8d3d5cfc409a617ee069759c93c08 +event-producer-consumer.zh.md: 42e79d5469f2b56e119b34b7dac63b8b590ff145 diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index f644336b2c..42e79d5469 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -71,8 +71,8 @@ | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## 包源码中出现的非 harness 或未声明事件字符串 diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 8587f8d35d..f6341b0335 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: d75bd368d5976813133bb8f62b1b5295bfb32b7c -README.zh.md: 589a72d81fd4e7d277b31626f896b41dc0ea1eb2 +README.md: f3340ebeb426fe9cc5c4570b3c8a3440cc1682ec +README.zh.md: 2e4523f03d1a942d9f93abf34ad2cc8687b413b8 diff --git a/docs/subsystems/activity.i18n.yaml b/docs/subsystems/activity.i18n.yaml deleted file mode 100644 index cf285e6ba7..0000000000 --- a/docs/subsystems/activity.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/subsystems/activity.md -activity.md: 000bdc30be05eb17d670367df3fd214e7d0af7f3 -activity.zh.md: 1fc79e9d596351a1f818ea83c15d1b5082689b79 diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 53ba3397ea..370043fe58 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/jobs.md -jobs.md: 092da7b6f7e10c5713d3d6c9c5c46f925084f86a -jobs.zh.md: 9dc390eb30901664edb47c6727a54ddd83839fa7 +jobs.md: 1b8e58f27be9e7198063960d436e61502c4f7e3b +jobs.zh.md: 0a1d04fcaf0ea0ceadcb457c756f0962e47eca34 diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 5928c640c4..0a1d04fcaf 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -23,7 +23,7 @@ interface JobKindMap { ## 生产方约定 -`JobStart` 声明身份和启动器。运行时会在调用 `run()` 前完成预检,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 +`JobStart` 声明身份和启动器。运行时会在完成预检后携带该 job 的生产者面调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 ```ts type-equiv /** @@ -38,7 +38,9 @@ interface JobStart { label: string /** * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. + * output read, including controller status metadata. Independent of record + * retention: it bounds the consuming model surface, never + * {@link JobRegistry.readRecord}. */ outputLimitBytes?: number /** @@ -49,11 +51,22 @@ interface JobStart { */ owner?: Agent /** - * Start the work after preflight and synchronously return its hooks. Called - * once; a throw leaves nothing registered, and the producer must clean up any - * partially started resources. + * Declares an observable output record beside the model-facing surfaces: + * {@link RunningJob.append} retains chunks in a bounded ring that any number + * of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + * Without the declaration `append` logs and drops. */ - run(): JobHooks + record?: true + /** + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the record append and live-detail writers. + */ + run(job: RunningJob): JobHooks } ``` @@ -71,13 +84,16 @@ interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. + * registry record without claiming that the work stopped. Settlement also + * ends the record: fold any record-filling pump into this promise so the + * final drain lands before the registry trims and closes the stream. */ done: Promise /** * Consume output produced since the previous call. The producer formats * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. + * job has one consuming cursor. Independent of the record: this is the + * model-facing projection, {@link JobRegistry.readRecord} the observer one. */ readOutput?(): string } @@ -95,6 +111,70 @@ interface JobOutcome { } ``` +## 观测 record + +声明 `record: true` 的生产方通过 starter 收到的 `RunningJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。 + +```ts type-equiv +/** + * Producer face of one registered job, handed to {@link JobStart.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — both methods log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. + */ +interface RunningJob { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** + * Append one record chunk. Offsets advance by the chunk's UTF-8 byte + * length; an empty chunk is dropped without waking observers. Without a + * {@link JobStart.record} declaration the chunk is logged and dropped. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: JobAppendOptions): void + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * Works for every job, with or without a record. + * @param detail - the new detail line. + */ + updateDetail(detail: string): void +} +``` + +```ts type-equiv +/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ +interface JobRecordChunk { + /** Absolute offset of the chunk's first byte. */ + at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + text: string + /** Stream label, when the producer supplied one. */ + channel?: JobChannel + /** Producer-reported loss immediately before this chunk. */ + gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link JobRegistry.readRecord}. */ +interface JobRecordRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly JobRecordChunk[] + /** + * Offset to resume from — the record's current `outputTotal`. Always a + * chunk boundary: appends land whole and trimming only advances chunk + * starts, and consumers concatenate `chunks` under that assumption, so a + * provider serving partial chunks would silently duplicate text. + */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean +} +``` + ## 消费方视图 快照是每次新建的只读投影。`ownerSession` 携带用于授权的共享 `SessionId`;完成监听器则会另行收到用于生命周期清理的确切拥有者对象。另一个接口已经交付终止状态或承诺交付时,`reported` 会抑制完成通知;排空 owner 或服务的 teardown 取消同样计入。 @@ -121,7 +201,10 @@ interface JobSnapshot { ownerSession?: SessionId /** Current lifecycle state. */ status: JobStatus - /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ + /** + * Kind-specific status detail: live progress while the producer updates it + * through {@link RunningJob.updateDetail}, the terminal detail once settled. + */ detail?: string /** Epoch ms when the job was registered. */ startedAt: number @@ -135,6 +218,17 @@ interface JobSnapshot { * otherwise spend a model request per teardown layer. */ reported: boolean + /** + * Total UTF-8 bytes ever appended to the record — the offset the next chunk + * starts at. Present exactly when the job declared {@link JobStart.record}. + */ + outputTotal?: number + /** + * Offset of the oldest retained record byte. Greater than zero exactly when + * retention dropped the head; a reader starting below it gets a lossy read. + * Present exactly when the job declared {@link JobStart.record}. + */ + outputEarliest?: number } ``` @@ -154,7 +248,7 @@ interface JobRead { ## 服务行为 -抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onJobDone` 与 `onJobsChanged` 监听器,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是其进程局部 Service Provider。授权会比较拥有者会话;拥有者清理与准入会使用确切的已注册 `Agent` 实例。本地 Service Provider 的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;它按确切 owner 统计 `running` 与 `stopping` 记录,所有无 owner 任务共享一个服务级桶,并在生产方终止结算后释放容量。Service Definition 约定见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md),注册表生命周期与准入策略见 [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md),面向模型的 Consumer 见 [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md)。 +抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、按调用方划定的 `get` 与 `list`、`read`、非消耗的 `readRecord`、`kill`、有界的 `wait`、故障隔离的 `onJobDone`、`onJobsChanged` 与 `onOutput` 监听器,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较 owner 会话;owner 清理与准入使用注册在案的确切 `Agent` 实例。本地 provider 的正安全整数配置 `maxConcurrentJobsPerOwner` 默认 `10`,按确切 owner 统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方终态结算释放容量,`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个已声明 record 的运行期与结算后保留量。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md)(Service Definition 契约)、[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md)(注册表生命周期与准入策略)与 [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md)(模型侧 Consumer)。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 92d25d2cfd..9b9953bdb4 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 2f2192cde2941019c9687738f8e3f5e50ddd6d8e -README.zh.md: 273d304d3cd3bbcbadd3e40202d8d923f3a27498 +README.md: 2e5ca38bf51a1aca881e0a15b28f0b7856b51f73 +README.zh.md: e316aac4c07692720d1c6809d52bbe3ab06be033 diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 273d304d3c..e316aac4c0 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,6 +29,8 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 +`SessionJob` 行恰在该 job 声明了观测 record 时携带 `outputTotal`,`session.observeJob` 从绝对字节偏移流式发送该 record——一个 `opened` 锚帧、聚合的 `output` 帧、最后一个终态 `status` 帧——围栏读取者由请求的 session 解析。record 读取是非消耗的:模型侧游标与完成通知状态永远观察不到它们。Client adapter 提供 `ctx.jobOutput`:按 job 引用计数的观测服务,任务列表 UI 渲染其累积视图;同一 job 的观察者共享一条流,重连时从模型游标续读。 + Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 ----- @@ -40,6 +42,8 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 |---|---:|---| | `coldBlankProbeMaxBytes` | `1,024` | 可进行空白状态验证的冷 Session 工件最大物理大小;`0` 禁用探测 | | `nativeOpen` | 平台探测 | 是否能把 Session 工作区路径交给原生桌面打开器 | +| `observeFlushMs` | `100` | 新 record 输出到一次 job 观测读取之间的聚合窗口,毫秒 | +| `observeMaxFrameBytes` | `65,536` | 每个 job 观测输出帧的软字节预算;更大的单块整块发送 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。 diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 6b75531c69..04d9e1336a 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: 3e45bac9b06e7006d955fc0cd8d600732e5f36ca -README.zh.md: e624cd5f87c450655c04b348cc72345b9389b0bd +README.md: 367a6d39ce1248b4246aa3e497093b5edb315fb0 +README.zh.md: 05a0143ce4eee253e1d94a1ff29e4c4f08da1274 diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml index 6e514ac7e3..e4822080f4 100644 --- a/packages/jobs/jobs-local/README.i18n.yaml +++ b/packages/jobs/jobs-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs-local/README.md -README.md: cfbda98ccf59bcd12e931350c708cc15b30ec54c -README.zh.md: 07acfc3c75ef730328792ab1aa45197bfcd5c849 +README.md: 2da0a0b3399ce5a55b63cad491ab6ee48a2f8a90 +README.zh.md: cc362ad7935a91a564cca5a82228934bcc15077d diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md index 07acfc3c75..cc362ad793 100644 --- a/packages/jobs/jobs-local/README.zh.md +++ b/packages/jobs/jobs-local/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新快照,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量;任务会随 harness 进程终止而消失,无法跨重启持久。 +`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新快照,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量,声明了 record 的任务另持有一个有界的内存输出环(默认运行期 256 KiB、结算时裁剪到 16 KiB),供观察者按绝对偏移读取;任务会随 harness 进程终止而消失,无法跨重启持久。 ## 目录 @@ -42,6 +42,8 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `maxConcurrentJobsPerOwner` | `10` | 每个精确所有者,或共享的无主桶中,`running` 加 `stopping` 任务的最大数量 | +| `retainBytes` | `262144` | 每个任务 record 的运行期保留量,UTF-8 字节 | +| `settledRetainBytes` | `16384` | 任务结算后保留的 record 量,UTF-8 字节 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-jobs-local)是每个受支持字段的穷尽式真源。 @@ -69,7 +71,8 @@ kind: "package-reference" ### 设计理念 -- **内存记录,全新快照。** `LocalJobRegistry` 为每个任务保存一条 `TrackedTask`,每次调用都投影出新的只读快照;调用方永远不会拿到实时状态。 +- **内存记录、全新快照。** `LocalJobRegistry` 为每个任务保存一份 `TrackedTask`——生命周期状态加可选的 record 环——每次调用都投影新的只读快照或块副本;调用方永远拿不到实时状态。 +- **record 保留量有界。** 追加超出运行期上限时丢弃最老的保留块(单个超限块保留其 UTF-8 安全尾部);低于保留窗口的读取得到 lossy 结果而非错误,结算时裁剪到结算上限。 - **按所有者分层,一个进程级注册表。** 控制器、完成监听器与变更观察者归档到注册方所在的 scope(`ScopedLayers`),读取把全局层与所有者的 scope 链求并集——因此某个 preset 的任务控制绝不会为自身组合未加载任何控制器的 agent 保持 `start()` 可用,一次结算也只会抵达其所有者所属组合注册的监听器。 - **启动前先预检。** `start()` 在调用生产方之前检查控制器服务、spec 有效性、仍存活的所有权与容量,因此拒绝不会留下 job id 或执行资源;注册一旦提交,后续不再有可失败步骤。 - **结算首次优先,完成最后。** 最早的终止结果只记录一次,释放等待方,并只通知监听器一次,各监听器故障单独隔离;完成在记录提交且可见集变更发布之后才宣布,因为报告方可能同步开启一个模型轮次。 @@ -84,11 +87,11 @@ kind: "package-reference" ### scope 分层 -`attachController`、`onJobDone` 与 `onJobsChanged` 注册到调用上下文所在的 scope 层。控制器问题(`servesOwner`)与监听器投递(`listenersFor`、`changedFor`)走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。 +`attachController`、`onJobDone`、`onJobsChanged` 与 `onOutput` 注册到调用上下文所在的 scope 层。控制器问题(`servesOwner`)与监听器投递(`listenersFor`、`changedFor`、`outputFor`)走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。 ### 准入与结算 -`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 在存在挂起等待方时把任务标为已报告,解析每个等待方,记录终止快照,宣布可见集变更,然后通知完成监听器。挂起的等待会在监听器运行前把任务标为已报告,因此完成报告方不会重复通知;销毁时的取消出于同样理由标记——面向正在被销毁的所有者的通知不会有人读到。 +`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 在存在挂起等待方时把任务标为已报告,把已声明的 record 裁剪到结算保留量,解析每个等待方,记录终止快照,宣布可见集变更与 record 的最终输出信号,然后通知完成监听器。挂起的等待会在监听器运行前把任务标为已报告,因此完成报告方不会重复通知;销毁时的取消出于同样理由标记——面向正在被销毁的所有者的通知不会有人读到。 ### 销毁 diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index f305c73ba6..9771023057 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -200,7 +200,7 @@ describe('LocalJobRegistry.start', () => { for (const job of live) ctx.jobs.start(job.spec) const blocked = producer() - const run = vi.fn(() => blocked.spec.run()) + const run = vi.fn((job: RunningJob) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('background job limit reached for this owner (limit: 10)') expect(run).not.toHaveBeenCalled() @@ -213,7 +213,7 @@ describe('LocalJobRegistry.start', () => { expect(ctx.jobs.start(first.spec)).toBe('bash-1') const blocked = producer() - const run = vi.fn(() => blocked.spec.run()) + const run = vi.fn((job: RunningJob) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('use job_kill to stop an unneeded job, wait for it to finish, then retry') expect(run).not.toHaveBeenCalled() @@ -658,7 +658,7 @@ describe('LocalJobRegistry owner isolation', () => { ctx.jobs.start(current.spec) // Attach the current owner's cleanup first. const stale = producer({ owner: staleOwner }) - const staleRun = vi.fn(() => stale.spec.run()) + const staleRun = vi.fn((job: RunningJob) => stale.spec.run(job)) expect(() => ctx.jobs.start({ ...stale.spec, run: staleRun })) .toThrow('is not the registered agent instance') expect(staleRun).not.toHaveBeenCalled() diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index faeca47c08..662a521a55 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md -README.md: 7625c3672d24edff908c7be01723e970bd44d1f7 -README.zh.md: d62953d325ad14479539ccb61fb51378d21e3b52 +README.md: 10c74af908f64c18616e3c1eb71f19a3cbbf4eb3 +README.zh.md: 1fc500311c58a538ebfc7fbd89886d9c12b96acd diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index d62953d325..1fc500311c 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。本包只提供约定:进程本地注册表位于 `dsh-jobs-local`,模型侧控制与完成通知位于 `dsh-tool-jobs`。加载一个实现才能获得后台任务;没有实现时 `ctx.jobs` 不存在,`start()` 无法运行。 +`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。生产方还可以声明按 job 划分的观测 record:一条追加式的有界输出流,任意数量的观察者(Web 客户端)按绝对字节偏移读取,不触碰模型侧的消耗型游标。本包只提供约定:进程本地注册表位于 `dsh-jobs-local`,模型侧控制与完成通知位于 `dsh-tool-jobs`。加载一个实现才能获得后台任务;没有实现时 `ctx.jobs` 不存在,`start()` 无法运行。 ## 目录 @@ -31,6 +31,8 @@ kind: "package-reference" 生产方以 kind 和一行标签注册工作;注册表返回 `-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新快照,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,拥有它的 agent 会通过 `dsh-tool-jobs` 转成会话内通知的完成监听器得到通知,因此无需轮询。生产方还可以附加可选的字节上限,让每次完整的模型侧输出读取或完成通知保持有界。 +声明 `record: true` 的生产方还会通过 starter 收到的 `RunningJob` 面把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。 + ### 归属边界 任务属于启动它的 agent 会话:其他 agent 无法读取或停止它。`bash-1` 这样的 id 可预测,因此这道隔离是授权,而非保密。没有所有者启动的任务对任何调用方开放,并持续到服务被释放为止。 @@ -75,13 +77,14 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 | -| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`JobSnapshot`、监听器类型 | +| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`JobSnapshot`、监听器类型 | | [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳与所有者字段 | +| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`:把生产方偏移读取器复制进 job record 的轮询泵 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳、所有者与 record 偏移字段 | ### 服务操作 -每个操作都是已注册任务之上的薄投影:`get` 与 `list` 返回非消费式快照,`read` 推进唯一的流游标,`kill` 在改变状态前调用生产方取消,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;监听器按所有者粒度观察终止记录与可见集变化,`attachController` 把控制器可用性限定在其 effect 生命周期内。确切签名与行为见 [`src/index.ts`](src/index.ts) 的 JSDoc 与生成的 [`ctx.jobs` cordis 接口面](../../../docs/subsystems/jobs.zh.md)。 +每个操作都是已注册任务之上的薄投影:`get` 与 `list` 返回非消费式快照,`read` 推进唯一的流游标,`readRecord` 按绝对偏移读取保留的 record 块且不消耗任何东西,`kill` 在改变状态前调用生产方取消,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;监听器按所有者粒度观察终止记录、可见集变化与 record 推进,`attachController` 把控制器可用性限定在其 effect 生命周期内。确切签名与行为见 [`src/index.ts`](src/index.ts) 的 JSDoc 与生成的 [`ctx.jobs` cordis 接口面](../../../docs/subsystems/jobs.zh.md)。 @@ -118,7 +121,7 @@ kind: "package-reference" 这些限制说明约定何时不合适。它们是当前包约束,不是任务积压。 - **约定是进程内的**——`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 -- **流输出只有一个消费游标**——它属于模型;独立观察者改用生产者镜像进去的非消费 [`dsh-activity`](../../activity/activity/README.zh.md) 观察面。 +- **流输出只有一个消费游标**——它属于模型;独立观察者改用非消耗的 `readRecord` 读取 job 声明的 record。 - **前台工作无法转为后台**——生产方在启动前选择前台或后台。 diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index 29ea9fb077..c979907a22 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-bash/README.md -README.md: 5e30ed598e894fc764e62366218f4e5ded0a7084 -README.zh.md: a62938a09f9a8ddd38094efca006932645d79d0d +README.md: 39e894970a92f68e09edf7b8dd10e040ed602b43 +README.zh.md: 025fb536a5196f4103a3476ba7ae20d5a59bda39 diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index a62938a09f..025fb536a5 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" ### 后台运行长时间命令 -传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。加载了可选的 `ctx.activities` 注册表时,已提交的后台运行还会被尽力镜像进去:一个与调用和 job 关联的 `bash` activity 以 `activityPollMs`(默认 150)的节奏泵送句柄的非消费 `observed` 读取器,使 Web 客户端在不触碰 job 游标的情况下流式看到实时输出;任何观察失败都被记录并吞掉。 +传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。后台 job 声明观测 record:运行的非消费 `observed` 读取器以 `recordPollMs`(默认 150)的节奏泵入其中,使 Web 客户端在不触碰 job 消耗游标的情况下流式看到实时输出;泵失败会被记录并吞掉,job 继续运行。 ### 沙箱执行与升权 diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index bd08faa835..baefd11561 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-pwsh/README.md -README.md: 8ea7e6f1e20f9c61f34fdc730c5c0b18706a2865 -README.zh.md: 2d8d51209a2ea66650904f10df263dfa0614aae3 +README.md: cfd6af5fe08a5f996d49c4b12c80930c687834fd +README.zh.md: 3b9a53047216d3ba80ca850f06bcf7a5c06bbfea diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 2d8d51209a..3b9a530472 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-tool-pwsh` 为 agent 提供 `pwsh` 工具,通过已挂载的 shell 执行器运行 PowerShell 命令——它是 `dsh-tool-bash` 的 Windows 对应物,逐调用镜像。每次调用都运行在全新 pwsh 进程中,因此状态不会保留;`run_in_background` 把长时间运行的命令变成后台任务。命令是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量,不做方言翻译。每次调用都运行在受管 `DSH_*` 环境中;在沙箱执行器下,工具会教授并执行 Windows 特有的语言模式与命名管道约定。请与 `dsh-pwsh-local` 等 PowerShell 执行器以及 `dsh-shell-env` 插件一起挂载。加载了可选的 `ctx.activities` 注册表时,已提交的后台运行还会被尽力镜像为按 `activityPollMs`(默认 150)泵送的 `pwsh` activity,与 `dsh-tool-bash` 完全一致。 +`dsh-tool-pwsh` 为 agent 提供 `pwsh` 工具,通过已挂载的 shell 执行器运行 PowerShell 命令——它是 `dsh-tool-bash` 的 Windows 对应物,逐调用镜像。每次调用都运行在全新 pwsh 进程中,因此状态不会保留;`run_in_background` 把长时间运行的命令变成后台任务。命令是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量,不做方言翻译。每次调用都运行在受管 `DSH_*` 环境中;在沙箱执行器下,工具会教授并执行 Windows 特有的语言模式与命名管道约定。请与 `dsh-pwsh-local` 等 PowerShell 执行器以及 `dsh-shell-env` 插件一起挂载。后台运行的 job 声明按 `recordPollMs`(默认 150)泵送的观测 record,与 `dsh-tool-bash` 完全一致。 ## 目录 diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index 719235042d..125c16d968 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/tool-workflow/README.md -README.md: a7096838da13f7f744bb1e6179a758d3dedc38a3 -README.zh.md: 254f498b4fe13397a035c97bfd7c708cde3415d7 +README.md: 2291b274a5cd52813266f4cb0e9276cd8019c4f8 +README.zh.md: d3009fa57d2ec2a16ed01c4926bb41066f1bbf2c diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index 254f498b4f..d3009fa57d 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -68,7 +68,7 @@ kind: "package-reference" 对于根 transport 执行(`exec.parent` 缺省),工具会用四个 log-only 事件把运行投影到调用 Agent 的 Session:`start()` 返回后写 run-start,只记录 `run.id` 匹配的成员开始与结束,并且只在结果可用且 dispose 完全停稳后写 run-end。嵌套 transport 调用照常执行,但不写任何记录。任一次 Session append 首次失败后,本运行会停止后续记录并只告警一次,留下空记录或合法连续前缀,同时不改变工具结果和清理。包 invariant 会在冷加载与实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点与 run-end 后更新,同时允许缺失终态后缀的连续前缀。 -在持久记录之外,每个被记录的顶层运行还会被尽力镜像进可选的 `ctx.activities` 注册表,作为一个 `workflow` activity:原本无人消费的 `workflow/phase` 与 `workflow/log` 事件加上成员生命周期行流入其中,使 Web 客户端看到会话日志刻意不逐行携带的实时叙述。没有注册表、或镜像出现任何失败时,运行本身不受影响。 +引擎的 `workflow/phase` 与 `workflow/log` 事件在本工具没有逐行的持久或观察面:会话日志刻意只记录 run 与成员生命周期,Web transcript 由这些记录派生。 ### 渲染意图 @@ -80,7 +80,6 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:工具注册、运行生命周期、记录器接线 | | [`src/types.ts`](src/types.ts) | 四个 log-only 记录事件 payload 及其 `SessionEventMap` 声明 | -| [`src/activity.ts`](src/activity.ts) | 顶层运行的尽力 activity 镜像 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:持久工作流记录协议校验 | diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 010c35a328..a217b3c619 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1016,6 +1016,21 @@ "symbol": "JobRead", "source": "packages/jobs/jobs/src/types.ts" }, + { + "doc": "docs/subsystems/jobs.md", + "symbol": "RunningJob", + "source": "packages/jobs/jobs/src/types.ts" + }, + { + "doc": "docs/subsystems/jobs.md", + "symbol": "JobRecordChunk", + "source": "packages/jobs/jobs/src/types.ts" + }, + { + "doc": "docs/subsystems/jobs.md", + "symbol": "JobRecordRead", + "source": "packages/jobs/jobs/src/types.ts" + }, { "doc": "docs/subsystems/terminal.md", "symbol": "TerminalWaitReason", From d2e9be40861f594c756745fa48d913b747627ca9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 18:36:29 +0800 Subject: [PATCH 018/141] test(api): stub session.observeJob on the fake client namespace The regenerated session namespace requires the new stream; the fake serves a never-yielding generator, and job-output.client.spec exercises the real consumer. --- packages/api/session-controller/tests/fake-api.client.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index e3ea48f783..e67650d4a2 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -10,7 +10,7 @@ import type { import type { SessionAddress, SessionControlBaseline, - SessionControlFrame, + SessionControlFrame, SessionObserveJobFrame, SessionFollowFrame, SessionFollowRequest, SessionPage, @@ -235,6 +235,9 @@ export class FakeApiClient { page: request => this.page(request), follow: (request, signal) => this.openFollow(request, signal), control: signal => this.openControl(signal), + // Job-record observation never yields here; the stream consumer is + // exercised directly by job-output.client.spec. + observeJob: () => (async function* (): AsyncGenerator {})(), }, subagents: { list: parentSessionId => this.record( From 419a985fc9741ae6f9eaa319d4f35dd24d98c6fc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 19:04:25 +0800 Subject: [PATCH 019/141] fix(api): capture the session namespace for job observation at apply time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observation (re)opens run on caller stacks — a React expand click, a carrier retry — whose dynamic context has not declared remote.session, so the ClientJobOutput wiring resolves the namespace to a concrete value while the plugin's own context is current, exactly as the activity client did. Caught by the live-job-stream web e2e, whose goldens (with the background-job-list pair) now carry the ui-jobs list name; the client-apply spec gains a jobOutput wiring smoke. --- .../snapshots/live-job-stream/settled.expected.md | 2 +- .../snapshots/live-job-stream/streaming.expected.md | 2 +- packages/api/session-controller/src/client/index.ts | 9 ++++++++- packages/api/session-controller/src/observe-job.ts | 6 ++++-- .../tests/client-apply.client.spec.ts | 12 ++++++++++++ .../web/background-job-list/running.expected.md | 2 +- .../web/background-job-list/settled.expected.md | 2 +- 7 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/snapshots/live-job-stream/settled.expected.md b/apps/web/tests/snapshots/live-job-stream/settled.expected.md index ecdcd4fea4..6ba8ada46d 100644 --- a/apps/web/tests/snapshots/live-job-stream/settled.expected.md +++ b/apps/web/tests/snapshots/live-job-stream/settled.expected.md @@ -1,4 +1,4 @@ -- list "Tasks": +- list "Background jobs": - listitem: - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM {{duration}}" diff --git a/apps/web/tests/snapshots/live-job-stream/streaming.expected.md b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md index aa23b11410..4054d5d42a 100644 --- a/apps/web/tests/snapshots/live-job-stream/streaming.expected.md +++ b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md @@ -1,4 +1,4 @@ -- list "Tasks": +- list "Background jobs": - listitem: - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - text: printf 'streamed-%s\n' marker-line; sleep 45 {{duration}} bash running diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 78a54b37ef..5fc9570a55 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -92,7 +92,14 @@ export const inject = [ export function apply(ctx: Context): void { const remotes = ctx.remote as unknown as SessionRemotes const sessions = new ClientSessions(ctx, remotes) - new ClientJobOutput(ctx, remotes, new ClientJobOutputModel()) + // Resolve the session namespace to a concrete value while this plugin's + // context is current: observation (re)opens run on caller stacks (a React + // event, a carrier retry) whose dynamic context has not declared + // `remote.session`, and a property access there fails the inject check. + new ClientJobOutput(ctx, { + $stream: options => remotes.$stream(options), + session: remotes.session, + }, new ClientJobOutputModel()) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) ctx.remote.$on('api-session/status', (sessionId, running) => { diff --git a/packages/api/session-controller/src/observe-job.ts b/packages/api/session-controller/src/observe-job.ts index 516f48779f..795e786455 100644 --- a/packages/api/session-controller/src/observe-job.ts +++ b/packages/api/session-controller/src/observe-job.ts @@ -1,7 +1,7 @@ /** Per-job record observation generations: anchor, coalesced output, terminal status. */ import type { Agent } from '@deepseek-ai/dsh-agent' -import { JobId } from '@deepseek-ai/dsh-jobs' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { JobRegistry, JobSnapshot } from '@deepseek-ai/dsh-jobs' import type { SessionJobWireChunk, SessionObserveJobFrame, SessionObserveJobRequest } from './types.ts' @@ -99,7 +99,9 @@ export async function* observeJobRecord( throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) } signal.throwIfAborted() - const id = JobId(String(request.jobId)) + // The brand is nominal typing only; the wire boundary stamps it here rather + // than value-importing the optional registry package's constructor. + const id = String(request.jobId) as JobId const waiter = new OutputWaiter() // Subscribe before the first read so an append between the anchor read and // the wait cannot be missed. diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 07f88b1a4f..b3fd854a68 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -106,6 +106,18 @@ async function flush(): Promise { } describe('Session Controller Client apply', () => { + it('installs ctx.jobOutput over the captured session namespace', async () => { + const bench = await mount() + expect(bench.ctx.jobOutput).toBeDefined() + // The fake observeJob never yields; opening and releasing must leave no + // view and no error behind. + const stop = bench.ctx.jobOutput.observe(sid('session-1'), 'bash-1' as never) + await flush() + stop() + await flush() + expect(bench.ctx.jobOutput.state.getSnapshot().observed).toEqual({}) + }) + it('routes Session Remote Events and connection generations into the object layer', async () => { const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected') const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError') diff --git a/snapshots/web/background-job-list/running.expected.md b/snapshots/web/background-job-list/running.expected.md index 95f226f226..811bddec3c 100644 --- a/snapshots/web/background-job-list/running.expected.md +++ b/snapshots/web/background-job-list/running.expected.md @@ -1,4 +1,4 @@ -- list "Tasks": +- list "Background jobs": - listitem: - button "Show live output of sleep 45": - text: sleep 45 {{duration}} bash running diff --git a/snapshots/web/background-job-list/settled.expected.md b/snapshots/web/background-job-list/settled.expected.md index 3343ae521c..44747d990d 100644 --- a/snapshots/web/background-job-list/settled.expected.md +++ b/snapshots/web/background-job-list/settled.expected.md @@ -1,4 +1,4 @@ -- list "Tasks": +- list "Background jobs": - listitem: - button "Show live output of sleep 45": - text: "bash sleep 45 signal: SIGTERM {{duration}}" From 1cec452f09a8fbe8f5a8c61cac39b257033ba9bc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 20:51:18 +0800 Subject: [PATCH 020/141] test(ci): close the observe-job coverage gaps Covers the woken-wait fast path (a wake landing while the generator is suspended on a yield), the opened frame's live-detail arm, and the sessionId-less caller resolution over an unowned job. --- .../tests/observe-job.host.spec.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/api/session-controller/tests/observe-job.host.spec.ts b/packages/api/session-controller/tests/observe-job.host.spec.ts index b61079d2b1..06a67848ec 100644 --- a/packages/api/session-controller/tests/observe-job.host.spec.ts +++ b/packages/api/session-controller/tests/observe-job.host.spec.ts @@ -54,6 +54,7 @@ function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; return { id, append: (text: string, opts?: Parameters[1]) => { face.append(text, opts) }, + updateDetail: (detail: string) => { face.updateDetail(detail) }, settle: async (outcome: JobOutcome) => { settle(outcome) await new Promise(resolve => setTimeout(resolve, 0)) @@ -224,6 +225,31 @@ describe('observeJobRecord', () => { await job.settle({ status: 'completed' }) }) + it('anchors with the live detail and takes the fast wait path for a wake that lands mid-yield', async () => { + const ctx = await harness() + const job = startRecordJob(ctx) + job.updateDetail('3/10') + const abort = new AbortController() + const iterator = observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, abort.signal)[Symbol.asyncIterator]() + + const anchor = await iterator.next() + expect(anchor.value).toMatchObject({ type: 'opened', detail: '3/10' }) + // Both chunks land while the generator is suspended on a yield, so each + // wake precedes the loop's wait(): draining the first chunk must pass + // through the already-woken fast path to read the second. + job.append('woken') + const first = await iterator.next() + expect(first.value).toMatchObject({ type: 'output' }) + job.append('again') + const second = await iterator.next() + expect(second.value).toMatchObject({ type: 'output' }) + const pending = iterator.next() + await job.settle({ status: 'completed' }) + const status = await pending + expect(status.value).toMatchObject({ type: 'status', status: 'completed' }) + expect((await iterator.next()).done).toBe(true) + }) + it('ignores output signals for other jobs while waiting', async () => { const ctx = await harness() const watched = startRecordJob(ctx, { label: 'watched' }) @@ -295,4 +321,24 @@ describe('SessionController.observeJob', () => { ) expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) }) + + it('observes an unowned job without a request session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + ctx.jobs.attachController('observe-job-test') + const controller = createSessionTestController(ctx, defaults) + const job = startRecordJob(ctx, { label: 'unowned run' }) + job.append('open access') + await job.settle({ status: 'completed' }) + + const abort = new AbortController() + const frames = await collect( + controller.observeJob({ jobId: job.id }, abort.signal), + 3, + abort, + ) + expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + }) }) From 7ec142a3258ef63f5c2b4253a347f9d4797610b6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 22:53:36 +0800 Subject: [PATCH 021/141] refactor(jobs): move job observation into api-job-controller Review asked for the observation wire and its client service to leave session-controller: a job should reach the Host only through its own Remote namespace, and the session plugin's apply had to materialize { $stream, session } for a service that is not session state. @deepseek-ai/dsh-api-job-controller now owns the Host JobController (job.observe over ctx.jobs, requiring the agent and job registries) and the client ClientJobOutput (ctx.jobOutput) over ClientJobOutputModel, resolving both Remote faces in its own apply. The remotes assembly mounts the job namespace; ui-jobs injects the new package; the wire types drop their Session prefix and spell the status union out so the client face pulls no Host declaration merges into browser programs. session-controller keeps the jobsBySession roster and loses observe-job, job-output, and the observe cadence config. ui-chat's slots and snapshot-builder import CommandNode and friends from the declaring package: the typert analyzer's cross-package rule trips on the snapshot re-export as soon as another client face joins the root Client program (the new controller does), which is why this branch carried the change before. JobPumpSource documents why it is an offset snapshot reader rather than a Node.js Readable. --- ...9-01-jobs-absorb-activity-record.i18n.yaml | 4 +- .../2026-09-01-jobs-absorb-activity-record.md | 4 +- ...26-09-01-jobs-absorb-activity-record.zh.md | 4 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 7 +- docs/capability-seams.zh.md | 7 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 24 +- docs/config-catalog.zh.md | 26 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 +- docs/event-producer-consumer.zh.md | 10 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 2 + docs/module-graph.zh.md | 2 + docs/subsystems/jobs.i18n.yaml | 4 +- docs/subsystems/jobs.md | 24 +- docs/subsystems/jobs.zh.md | 24 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 12 - docs/subsystems/session.zh.md | 12 - packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 1 + packages/api/README.zh.md | 1 + packages/api/job-controller/README.i18n.yaml | 6 + packages/api/job-controller/README.md | 66 ++++ packages/api/job-controller/README.zh.md | 66 ++++ packages/api/job-controller/package.json | 86 +++++ .../api/job-controller/src/client/index.ts | 34 ++ .../api/job-controller/src/client/model.ts | 179 +++++++++ .../api/job-controller/src/client/service.ts | 179 +++++++++ packages/api/job-controller/src/index.ts | 86 +++++ .../src/observe.ts} | 14 +- packages/api/job-controller/src/types.ts | 75 ++++ .../tests/client-apply.client.spec.ts | 65 ++++ .../tests/model.client.spec.ts} | 13 +- .../tests/observe.host.spec.ts} | 66 ++-- .../api/job-controller/tsconfig.client.json | 23 ++ .../api/job-controller/tsconfig.host.json | 21 ++ packages/api/job-controller/tsconfig.json | 7 + packages/api/job-controller/tsdown.config.ts | 7 + packages/api/remotes/package.json | 11 +- packages/api/remotes/src/client/index.ts | 5 +- packages/api/remotes/tsconfig.client.json | 4 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 4 +- packages/api/session-controller/README.zh.md | 4 +- .../session-controller/src/client/index.ts | 11 - .../src/client/job-output.ts | 348 ------------------ packages/api/session-controller/src/index.ts | 43 --- packages/api/session-controller/src/types.ts | 57 +-- .../tests/client-apply.client.spec.ts | 12 - .../tests/fake-api.client.ts | 5 +- .../session-controller/tests/test-remote.ts | 4 - .../api/session-controller/tsconfig.host.json | 101 +++-- packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 43 +-- .../ui-chat/src/client/contract/slots.ts | 7 +- .../chat-snapshot-builder.ts | 7 +- packages/client/ui-jobs/README.i18n.yaml | 4 +- packages/client/ui-jobs/README.md | 5 +- packages/client/ui-jobs/README.zh.md | 5 +- packages/client/ui-jobs/package.json | 2 + .../ui-jobs/src/client/JobListAction.tsx | 2 +- packages/client/ui-jobs/src/client/index.ts | 1 + .../tests/job-list-action.client.spec.tsx | 2 +- packages/client/ui-jobs/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 12 +- .../extensions/tool-cordis/src/api-catalog.ts | 47 ++- packages/jobs/jobs/src/pump.ts | 14 +- pnpm-lock.yaml | 49 +++ scripts/gen-cordis-catalog.ts | 8 +- scripts/gen-doc-graphs.ts | 9 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 3 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 77 files changed, 1339 insertions(+), 700 deletions(-) create mode 100644 packages/api/job-controller/README.i18n.yaml create mode 100644 packages/api/job-controller/README.md create mode 100644 packages/api/job-controller/README.zh.md create mode 100644 packages/api/job-controller/package.json create mode 100644 packages/api/job-controller/src/client/index.ts create mode 100644 packages/api/job-controller/src/client/model.ts create mode 100644 packages/api/job-controller/src/client/service.ts create mode 100644 packages/api/job-controller/src/index.ts rename packages/api/{session-controller/src/observe-job.ts => job-controller/src/observe.ts} (94%) create mode 100644 packages/api/job-controller/src/types.ts create mode 100644 packages/api/job-controller/tests/client-apply.client.spec.ts rename packages/api/{session-controller/tests/job-output.client.spec.ts => job-controller/tests/model.client.spec.ts} (98%) rename packages/api/{session-controller/tests/observe-job.host.spec.ts => job-controller/tests/observe.host.spec.ts} (89%) create mode 100644 packages/api/job-controller/tsconfig.client.json create mode 100644 packages/api/job-controller/tsconfig.host.json create mode 100644 packages/api/job-controller/tsconfig.json create mode 100644 packages/api/job-controller/tsdown.config.ts delete mode 100644 packages/api/session-controller/src/client/job-output.ts diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml index 87e45ad40a..3a332e7121 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md -2026-09-01-jobs-absorb-activity-record.md: cc2cc8606a50e2d7c06e183a09df709b75b9bd35 -2026-09-01-jobs-absorb-activity-record.zh.md: 8b6739192b56899899773ba1e7af2bf1bd846b0b +2026-09-01-jobs-absorb-activity-record.md: aa147778d06133b61f79defae6f9f6807dd97f41 +2026-09-01-jobs-absorb-activity-record.zh.md: 12e8f4bbd909a480f1461433c58d7937a8d7c857 diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md index cc2cc8606a..aa147778d0 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -18,7 +18,7 @@ The one activity without a job was the foreground workflow mirror. Everything el - **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented. - **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design. - **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it. -- **The wire moves to the session namespace.** `SessionJob.outputTotal` (present exactly for record jobs) marks a row observable; `session.observeJob({sessionId?, jobId, from?})` streams anchor/output/status frames with the fenced read resolved from the request's session — the killJob authorization shape. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery moved into `session-controller` (`observe-job.ts`, client `ctx.jobOutput`). +- **The wire splits by role.** `SessionJob.outputTotal` (present exactly for record jobs) marks a row observable on the session control stream; `job.observe({sessionId?, jobId, from?})` on the `job` namespace of `api-job-controller` streams anchor/output/status frames with the fenced read resolved from the request's session. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery lives in `api-job-controller` (`observe.ts`, client `ctx.jobOutput`), which resolves its Remote faces in its own apply. - **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `outputTotal` is present. The two-roster join is deleted. - **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows. @@ -32,4 +32,4 @@ The [prior seam note](../feature/2026-08-24-activity-observation-seam.md)'s argu ## Consequences -Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `session-controller`. +Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `api-job-controller`. diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md index 8b6739192b..12e8f4bbd9 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -18,7 +18,7 @@ Status: implemented - **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。 - **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。 - **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。 -- **wire 移入 session 命名空间。** `SessionJob.outputTotal`(恰在 record job 上存在)标记行可观测;`session.observeJob({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析——与 killJob 相同的授权形态。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制移入 `session-controller`(`observe-job.ts`,客户端 `ctx.jobOutput`)。 +- **wire 按角色拆分。** `SessionJob.outputTotal`(恰在 record job 上存在)在会话控制流上标记行可观测;`api-job-controller` 的 `job` 命名空间上的 `job.observe({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制位于 `api-job-controller`(`observe.ts`,客户端 `ctx.jobOutput`),它在自己的 apply 里解析所依赖的 Remote 面。 - **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `outputTotal` 存在时可展开。双名册 join 被删除。 - **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。 @@ -32,4 +32,4 @@ Status: implemented ## 后果 -每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `session-controller`。 +每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `api-job-controller`。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 0f57f09f95..902b8a63bf 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 635ef1defb7acabf78d94b28c6bef66a43f518b3 -capability-seams.zh.md: 21619c9d20f66edee269a1599ced6f4d4f55df38 +capability-seams.md: 30b3a2f8fdbb66410ada6ab27551d6018a1ac8eb +capability-seams.zh.md: 834131588213627cefd4a6ea9c69d67f5d345ba7 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 635ef1defb..30b3a2f8fd 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -39,6 +39,8 @@ flowchart LR svc_sessionController["ctx.sessionController
        Host Session Remote controller"] svc_sessionFileReferences["ctx.sessionFileReferences
        Session-addressed file-reference Remote adapter"] svc_sessionSkillCatalog["ctx.sessionSkillCatalog
        Session-addressed skill Remote adapter"] + pkg_api_job_controller["api-job-controller"] + svc_jobController["ctx.jobController
        Host job Remote controller"] pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
        Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
        Host settings-surface Remote controller"] @@ -223,6 +225,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway + pkg_api_job_controller --> svc_jobController pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -369,6 +372,7 @@ flowchart LR svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope svc_invariants --> pkg_session + svc_jobs --> pkg_api_job_controller svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs svc_jobs --> pkg_tool_pwsh @@ -475,6 +479,7 @@ flowchart LR | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Owns Session commands, cold reads, durable-event following, live control state, model catalogs, workspace opening, and Agent activation policy. | | `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Delegates file-reference discovery through the Session Controller's established Agent lookup policy. | | `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Lists the Session composition's user-invocable skills without activating a cold Agent. | +| `ctx.jobController` | `core` | [`api-job-controller`](../packages/api/job-controller) | - | - | - | Streams one background job's observation record over the generated Remote namespace; the roster stays on the session control stream. | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | @@ -524,7 +529,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 21619c9d20..8341315882 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -41,6 +41,8 @@ flowchart LR svc_sessionController["ctx.sessionController
        Host Session Remote controller"] svc_sessionFileReferences["ctx.sessionFileReferences
        Session-addressed file-reference Remote adapter"] svc_sessionSkillCatalog["ctx.sessionSkillCatalog
        Session-addressed skill Remote adapter"] + pkg_api_job_controller["api-job-controller"] + svc_jobController["ctx.jobController
        Host job Remote controller"] pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
        Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
        Host settings-surface Remote controller"] @@ -225,6 +227,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway + pkg_api_job_controller --> svc_jobController pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -371,6 +374,7 @@ flowchart LR svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope svc_invariants --> pkg_session + svc_jobs --> pkg_api_job_controller svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs svc_jobs --> pkg_tool_pwsh @@ -477,6 +481,7 @@ flowchart LR | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态、模型目录、workspace 打开与 Agent 激活策略。 | | `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 通过 Session Controller 的既有 Agent lookup 策略委托文件引用发现。 | | `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 在不激活冷 Agent 的前提下列出 Session 组合中允许用户调用的 skill。 | +| `ctx.jobController` | `core` | [`api-job-controller`](../packages/api/job-controller) | - | - | - | 经生成的 Remote namespace 流式发送一个后台任务的观测 record;名册仍在会话控制流上。 | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | @@ -526,7 +531,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c065e16e00..102b6810cd 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 2c8043732edb0d47e5eaffd02f95e4293060a4dd -config-catalog.zh.md: 24c077ca51e64a4f6235170726a909be9e0e5207 +config-catalog.md: 36eb0f1e9385b9ddc4a09a9476d3b483075282ba +config-catalog.zh.md: 90e94c3e739dd854c1a4243297d1e5aef557f828 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2c8043732e..36eb0f1e93 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -199,6 +199,24 @@ export interface Config { Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts) + + +## `@deepseek-ai/dsh-api-job-controller` + +Requires: `agents` · `jobs` · `typert` + +```ts config-catalog +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number +} +``` + +Source: [`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -214,14 +232,10 @@ export interface Config { readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean - /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ - readonly observeFlushMs?: number - /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ - readonly observeMaxFrameBytes?: number } ``` -Source: [`packages/api/session-controller/src/index.ts:82`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 24c077ca51..90e94c3e73 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -201,6 +201,26 @@ export interface Config { 来源:[`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts) + + +## `@deepseek-ai/dsh-api-job-controller` + +需要:`agents` · `jobs` · `typert` + +```ts config-catalog +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number +} +``` + +来源:[`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts) + + + ## `@deepseek-ai/dsh-api-session-controller` @@ -216,14 +236,10 @@ export interface Config { readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean - /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ - readonly observeFlushMs?: number - /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ - readonly observeMaxFrameBytes?: number } ``` -来源:[`packages/api/session-controller/src/index.ts:82`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 1efb0f757c..8a378b23f1 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 26096b99ef32702b85a622fa0342f215bb90ddf9 -event-producer-consumer.zh.md: 3e224d7a192e1c8c361fe2c7ad88b2d817bfbb1e +event-producer-consumer.md: 930ffba24789ee51db110310cb17a2231135cf1d +event-producer-consumer.zh.md: 6583b1d9f333ad934db2123a2bc5c40b12f1ae36 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 26096b99ef..930ffba247 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:550`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:557`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 3e224d7a19..6583b1d9f3 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:550`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:557`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 7a1a91a45a..64db381cab 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 514d09c5883ec8196e0706fbd7a3ccf45b8aafb5 -module-graph.zh.md: 444100e17ce6794efa39ef2e0a10872a6e1c3484 +module-graph.md: e280a648b8550d34b129f05971ebd0547690f117 +module-graph.zh.md: b068b7216481fb86873c290d31e6d76ce85e3fbd diff --git a/docs/module-graph.md b/docs/module-graph.md index 514d09c588..e280a648b8 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -108,6 +108,7 @@ flowchart TD end subgraph group_api["packages/api"] pkg_api_gateway["api-gateway"] + pkg_api_job_controller["api-job-controller"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -1153,6 +1154,7 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | +| [`api-job-controller`](../packages/api/job-controller) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 444100e17c..b068b72164 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -110,6 +110,7 @@ flowchart TD end subgraph group_api["packages/api"] pkg_api_gateway["api-gateway"] + pkg_api_job_controller["api-job-controller"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -1155,6 +1156,7 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | +| [`api-job-controller`](../packages/api/job-controller) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 370043fe58..022befcbd2 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/jobs.md -jobs.md: 1b8e58f27be9e7198063960d436e61502c4f7e3b -jobs.zh.md: 0a1d04fcaf0ea0ceadcb457c756f0962e47eca34 +jobs.md: 0610d99892da22a25590ab7efe8ff71d9a004f01 +jobs.zh.md: 4c35fd7c879ddf7133c0db55df24948b40118b9d diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 1b8e58f27b..0610d99892 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -113,7 +113,7 @@ interface JobOutcome { ## Observation record -A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. +A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. Browsers reach the record through `job.observe`, the Remote stream of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below. ```ts type-equiv /** @@ -258,6 +258,28 @@ The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Defi Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.jobController` — `JobController` + +Host service backing the generated `ctx.remote.job` namespace. + +```ts cordis-catalog +/** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts) + ### `ctx.jobs` — `JobRegistry` (abstract seam) diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 0a1d04fcaf..4c35fd7c87 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -113,7 +113,7 @@ interface JobOutcome { ## 观测 record -声明 `record: true` 的生产方通过 starter 收到的 `RunningJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。 +声明 `record: true` 的生产方通过 starter 收到的 `RunningJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。浏览器经 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.observe` 读取 record,其帧类型列在下方该控制器的 Cordis API 一节。 ```ts type-equiv /** @@ -258,6 +258,28 @@ interface JobRead { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.jobController` — `JobController` + +Host service backing the generated `ctx.remote.job` namespace. + +```ts cordis-catalog +/** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts) + ### `ctx.jobs` — `JobRegistry` (abstract seam) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 3a2bd2ec51..f9653cecea 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 36c15ffd5244da732dea003259d476c0d1812d89 -session.zh.md: b31fd6593cb9d72c6577ee7a94c02d6dd04994e4 +session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31 +session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 36c15ffd52..48a80ccb92 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -800,18 +800,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise - -/** - * Stream one job's retained record output from an absolute byte offset, - * then its terminal status once settled and drained. Non-consuming: the - * model-facing cursor and notice state never observe these reads. The - * request's session resolves the fenced-read caller; the registry rejects a - * job the session does not own, a job without a record, or an unknown job. - * @param request - target job, owning session, and optional resume offset. - * @param signal - cancellation owned by the Remote stream carrier. - * @returns anchor, coalesced output frames, and the terminal status. - */ -@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable ``` Types: [SessionId](core.md) · [SessionInspection](persistence.md) · [SessionSearchRequest](session-query.md) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index b31fd6593c..df5cfd631a 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -804,18 +804,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise - -/** - * Stream one job's retained record output from an absolute byte offset, - * then its terminal status once settled and drained. Non-consuming: the - * model-facing cursor and notice state never observe these reads. The - * request's session resolves the fenced-read caller; the registry rejects a - * job the session does not own, a job without a record, or an unknown job. - * @param request - target job, owning session, and optional resume offset. - * @param signal - cancellation owned by the Remote stream carrier. - * @returns anchor, coalesced output frames, and the terminal status. - */ -@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable ``` Types: [SessionId](core.zh.md) · [SessionInspection](persistence.zh.md) · [SessionSearchRequest](session-query.zh.md) diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index c47c67f406..d40b9dfa53 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/README.md -README.md: b4d8ddd84baa411a2675c95dda1701937e06fe6d -README.zh.md: 5dff7a59219a539c75d7ab85d293cd5b389018ec +README.md: ad89535e8d482e7a50868cd97e0af50ebda62e4b +README.zh.md: 7e1957967aadcd93df5e1bb7f6db45cb4636c750 diff --git a/packages/api/README.md b/packages/api/README.md index b4d8ddd84b..ad89535e8d 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -28,6 +28,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust |---|---|---| | [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — | | [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`job-controller/`](job-controller/README.md) | Streams one background job's observation record to the Client. | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 5dff7a5921..7e1957967a 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -28,6 +28,7 @@ kind: "package-group" |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | | [`gateway/`](gateway/README.zh.md) | 承载带类型的单次调用、多路复用 stream 与转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`job-controller/`](job-controller/README.zh.md) | 把一个后台任务的观测 record 流式送到 Client。 | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.zh.md) | 拥有 Session 命令、历史 stream、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | diff --git a/packages/api/job-controller/README.i18n.yaml b/packages/api/job-controller/README.i18n.yaml new file mode 100644 index 0000000000..9bef50c478 --- /dev/null +++ b/packages/api/job-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/job-controller/README.md +README.md: 1af9cac6574b36fb9ee0fe2bd4ff4a529848c2fe +README.zh.md: a232d8e8655fea6ccddedb60e222837466705549 diff --git a/packages/api/job-controller/README.md b/packages/api/job-controller/README.md new file mode 100644 index 0000000000..1af9cac657 --- /dev/null +++ b/packages/api/job-controller/README.md @@ -0,0 +1,66 @@ +--- +description: "Host and Client job observation: stream one background job's output record to the browser without touching the model's consuming cursor." +kind: "package-reference" +--- +# Job Controller + +English | [中文](README.zh.md) + +## Summary + +`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its one Remote stream, `job.observe`, delivers a record-declaring job's retained output from an absolute byte offset; the Client half installs `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views the session-header job list renders. The roster itself is not here: `SessionJob` rows still ride the session control stream owned by [`dsh-api-session-controller`](../session-controller/README.md), and a row's `outputTotal` says whether this controller has anything to stream for it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The Host controller requires the live Agent registry and the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without them. `job.observe({ sessionId?, jobId, from? })` resolves the fenced-read caller from the request's session — an unowned job needs no session — and yields one `opened` anchor, coalesced `output` frames, then one terminal `status` once the job has settled and its record is drained, after which the stream closes normally. Record reads are non-consuming: the model-facing `job_output` cursor and completion-notice state never observe them. A job the session does not own, a job without a record, or an unknown job rejects the stream. Reconnecting callers resume by passing the last frame's `next` as `from`; a `from` below the oldest retained byte gets a `lossy` first frame instead of an error. + +The Client entry provides `ClientJobOutput` (`ctx.jobOutput`) over `ClientJobOutputModel`. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, records the terminal status or a stream failure on the view, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because observation (re)opens run on caller stacks that have not declared `remote.job`. + +### Config + +| Field | Default | Meaning | +|---|---:|---| +| `observeFlushMs` | `100` | Coalescing window after new record output before an observation read, in milliseconds | +| `observeMaxFrameBytes` | `65,536` | Soft byte budget per observation output frame; one larger chunk ships whole | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-job-controller) is the exhaustive source for accepted fields and their JSDoc. + +----- + + +## Model Experience + +None, as job observation is browser and Host control state; it registers no prompt, tool, or session event. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). + +#### KV Cache effect + +No direct effect; observation reads never touch model requests. + +## Known Limitations and Deferred Work + + + +- The stream is process-local: a Host restart loses every record, and a resumed observation then anchors on an empty registry. +- Per-session fencing is enforced by the registry read; the Remote layer itself serves any connected browser, matching the session control stream's `jobsBySession` broadcast. + + +### Dev Note + +
        +Working context for maintainers — click to expand + +None. + +
        + +**Runtime invariant:** No companion is published. The controller is a stateless projection of `ctx.jobs` reads; the registry's own `@deepseek-ai/dsh-jobs/invariant` owns the offset relations this stream forwards. diff --git a/packages/api/job-controller/README.zh.md b/packages/api/job-controller/README.zh.md new file mode 100644 index 0000000000..a232d8e865 --- /dev/null +++ b/packages/api/job-controller/README.zh.md @@ -0,0 +1,66 @@ +--- +description: "Host 与 Client 的 job 观测:把一个后台任务的输出 record 流式送到浏览器,不触碰模型的消耗型游标。" +kind: "package-reference" +--- +# Job Controller + +[English](README.md) | 中文 + +## 概述 + +`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它唯一的 Remote 流 `job.observe` 从绝对字节偏移发送声明了 record 的 job 的保留输出;Client 半侧安装 `ctx.jobOutput`——按 job 引用计数的观测服务,会话头部任务列表渲染其累积视图。名册不在这里:`SessionJob` 行仍随 [`dsh-api-session-controller`](../session-controller/README.zh.md) 拥有的会话控制流到达,行上的 `outputTotal` 说明本控制器是否有东西可流。 + +## 目录 + +- [使用本包](#use-this-package) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +Host 控制器要求活体 Agent 注册表与 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少任一则不加载。`job.observe({ sessionId?, jobId, from? })` 由请求的 session 解析围栏读取者——无主 job 不需要 session——先产出一个 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且 record 排干后产出一个终态 `status`,随后流正常关闭。record 读取是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。session 不拥有的 job、没有 record 的 job 或未知 job 会拒绝该流。重连方把上一帧的 `next` 作为 `from` 续读;低于最老保留字节的 `from` 得到带 `lossy` 的首帧而非错误。 + +Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jobOutput`)。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,把终态或流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为观测的(重)开启跑在未声明 `remote.job` 的调用栈上。 + +### 配置 + +| 字段 | 默认值 | 含义 | +|---|---:|---| +| `observeFlushMs` | `100` | 新 record 输出到一次观测读取之间的聚合窗口,毫秒 | +| `observeMaxFrameBytes` | `65,536` | 每个观测输出帧的软字节预算;更大的单块整块发送 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-job-controller)是所有受支持字段及其 JSDoc 的完整来源。 + +----- + + +## 模型体验 + +无。job 观测是浏览器与 Host 的控制状态;它不注册任何提示词、工具或会话事件。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 + +#### KV 缓存影响 + +无直接影响;观测读取从不触碰模型请求。 + +## 已知限制与延期工作 + + + +- 流是进程本地的:Host 重启丢失全部 record,续读的观测随后锚定在空注册表上。 +- 按 session 的围栏由注册表读取强制;Remote 层本身服务任何已连接浏览器,与会话控制流对 `jobsBySession` 的广播一致。 + + +### 开发备注 + +
        +维护者的工作上下文——点击展开 + +无。 + +
        + +**运行时不变式:** 不发布伴生入口。控制器是 `ctx.jobs` 读取的无状态投影;本流转发的偏移关系由注册表自己的 `@deepseek-ai/dsh-jobs/invariant` 拥有。 diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json new file mode 100644 index 0000000000..0f0e46aafe --- /dev/null +++ b/packages/api/job-controller/package.json @@ -0,0 +1,86 @@ +{ + "name": "@deepseek-ai/dsh-api-job-controller", + "description": "Job Remote observation stream and the reference-counted client job-output service", + "version": "0.1.2-alpha.5", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/job-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^" + } +} diff --git a/packages/api/job-controller/src/client/index.ts b/packages/api/job-controller/src/client/index.ts new file mode 100644 index 0000000000..a76d507332 --- /dev/null +++ b/packages/api/job-controller/src/client/index.ts @@ -0,0 +1,34 @@ +/** + * Job Controller client half: installs `ctx.jobOutput` over the generated + * `job` Remote namespace. The plugin resolves both Remote faces it drives + * while its own context is current, because observation (re)opens run on + * caller stacks — a React event, a carrier retry — whose dynamic context has + * not declared `remote.job`. + * @module @deepseek-ai/dsh-api-job-controller/client + */ + +import type { Context } from '@deepseek-ai/cordis' +import { ClientJobOutputModel } from './model.ts' +import { ClientJobOutput } from './service.ts' +import type { JobObserveRemote } from './service.ts' + +export { ClientJobOutputModel } from './model.ts' +export type { JobOutputSnapshot, JobOutputSource, ObservedJob } from './model.ts' +export { ClientJobOutput } from './service.ts' +export type { IJobOutput, JobObserveRemote, JobRemote } from './service.ts' +export type { JobObserveFrame, JobObserveRequest, JobObserveStatus, JobWireChunk } from '../types.ts' + +/** Required Client Remote services. */ +export const inject = ['remote', 'remote.job'] + +/** + * Install the client job-output service. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + const remotes = ctx.remote as unknown as JobObserveRemote + new ClientJobOutput(ctx, { + $stream: options => remotes.$stream(options), + job: remotes.job, + }, new ClientJobOutputModel()) +} diff --git a/packages/api/job-controller/src/client/model.ts b/packages/api/job-controller/src/client/model.ts new file mode 100644 index 0000000000..1c44aa039a --- /dev/null +++ b/packages/api/job-controller/src/client/model.ts @@ -0,0 +1,179 @@ +/** + * React-free client job-record observation state: per-job accumulated output + * views fed by `job.observe` frames. Pure data plus subscriptions — transport + * wiring stays in the client service, UI stays in slot components. + * @module @deepseek-ai/dsh-api-job-controller/client/model + */ + +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { JobObserveFrame, JobObserveStatus } from '../types.ts' + +/** Bounded per-job render tail, in UTF-16 code units. */ +const RENDER_TAIL_LIMIT = 128 * 1024 + +/** One observed job's live view state. */ +export interface ObservedJob { + readonly jobId: JobId + /** Accumulated output tail, bounded to the render limit. */ + readonly text: string + /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ + readonly gapBefore: boolean + /** Latest status delivered on the observation stream. */ + readonly status: JobObserveStatus + readonly detail?: string + /** True while the observation stream is open and the job has not settled. */ + readonly streaming: boolean + /** Terminal observation failure, when the stream ended abnormally. */ + readonly error?: string +} + +/** Immutable client job-output state. */ +export interface JobOutputSnapshot { + /** Live observation state keyed by job id. */ + readonly observed: Readonly> +} + +/** Bare observable source for the client job-output snapshot. */ +export interface JobOutputSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): JobOutputSnapshot + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void +} + +/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */ +interface ObservedState { + view: ObservedJob + /** Resume offset for the next generation's `from`. */ + cursor: number | undefined +} + +/** Owns per-job observation state. */ +export class ClientJobOutputModel implements JobOutputSource { + private readonly observedStates = new Map() + private readonly listeners = new Set<() => void>() + private snapshotCache: JobOutputSnapshot = { observed: {} } + private snapshotDirty = false + + getSnapshot(): JobOutputSnapshot { + if (this.snapshotDirty) { + const observed: Record = {} + for (const [id, state] of this.observedStates) observed[id] = state.view + this.snapshotCache = { observed } + this.snapshotDirty = false + } + return this.snapshotCache + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * The resume offset for one job's next observation generation. + * @param id - observed job. + * @returns the last accepted `next`, or undefined for a fresh observation. + */ + cursorOf(id: JobId): number | undefined { + return this.observedStates.get(String(id))?.cursor + } + + /** + * Install or reset observation state when a generation's anchor arrives. + * @param id - observed job. + * @param frame - the generation's `opened` anchor. + */ + observeOpened(id: JobId, frame: Extract): void { + const existing = this.observedStates.get(String(id)) + // A fresh view anchored past offset zero starts after an evicted head + // (fresh observations anchor at the registry's earliest retained byte), so + // it owes the same gap mark a live observer earned from lossy reads. A + // resume that already accumulated text keeps its recorded gap state. + const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 + const view: ObservedJob = { + jobId: id, + text: existing?.view.text ?? '', + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: true, + } + this.observedStates.set(String(id), { view, cursor: frame.from }) + this.changed() + } + + /** + * Append one output frame's chunks to the bounded render tail. + * @param id - observed job. + * @param frame - a coalesced `output` frame. + */ + observeOutput(id: JobId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') + let gapBefore = state.view.gapBefore || frame.lossy === true + || frame.chunks.some(chunk => chunk.gapBefore === true) + if (text.length > RENDER_TAIL_LIMIT) { + let cut = text.length - RENDER_TAIL_LIMIT + // Never split a surrogate pair at the render bound. + const unit = text.charCodeAt(cut) + if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 + text = text.slice(cut) + gapBefore = true + } + state.view = { ...state.view, text, gapBefore } + state.cursor = frame.next + this.changed() + } + + /** + * Record the terminal status delivered at the end of the observation stream. + * @param id - observed job. + * @param frame - the terminal `status` frame. + */ + observeStatus(id: JobId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + state.view = { + ...state.view, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: false, + } + this.changed() + } + + /** + * Record a terminal observation failure. + * @param id - observed job. + * @param error - the stream's terminal failure. + */ + observeFailed(id: JobId, error: unknown): void { + const state = this.observedStates.get(String(id)) + if (state === undefined) return + state.view = { ...state.view, streaming: false, error: String(error) } + this.changed() + } + + /** + * Drop observation state after the last observer stops. + * @param id - the no-longer-observed job. + */ + observeStopped(id: JobId): void { + if (!this.observedStates.delete(String(id))) return + this.changed() + } + + private changed(): void { + this.snapshotDirty = true + notifySubscribers(this.listeners, 'job output') + } +} diff --git a/packages/api/job-controller/src/client/service.ts b/packages/api/job-controller/src/client/service.ts new file mode 100644 index 0000000000..385a0b92ad --- /dev/null +++ b/packages/api/job-controller/src/client/service.ts @@ -0,0 +1,179 @@ +/** + * The `ctx.jobOutput` client service: reference-counted per-job observation + * streams over `job.observe`, so overlapping viewers of one job share one + * stream and resume from the model's cursor across reconnects. + * @module @deepseek-ai/dsh-api-job-controller/client/service + */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { JobObserveFrame, JobObserveRequest } from '../types.ts' +import type { ClientJobOutputModel, JobOutputSource } from './model.ts' + +/** The generated `job` namespace face the observation runner drives. */ +export interface JobRemote { + /** + * Open one observation generation. + * @param request - target job, owning session, and optional resume offset. + * @param signal - generation cancellation. + * @returns the frame sequence of one generation. + */ + observe(request: JobObserveRequest, signal?: AbortSignal): AsyncIterable +} + +/** Remote faces the observation runner drives: the Gateway stream factory and the `job` namespace. */ +export interface JobObserveRemote { + readonly $stream: ClientRemote['$stream'] + readonly job: JobRemote +} + +/** The job-output client service face. */ +export interface IJobOutput { + /** Per-job observation state. */ + readonly state: JobOutputSource + /** + * Start observing one job's live record; reference-counted, so two viewers + * of the same job share one stream. + * @param sessionId - owning session used for the fenced read; undefined for an unowned job. + * @param id - job to observe. + * @returns stop function releasing this observer's reference. + */ + observe(sessionId: SessionId | undefined, id: JobId): () => void +} + +/** One reference-counted observation stream. */ +interface ObservationEntry { + refs: number + stopped: boolean + dispose: () => Promise +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free client job-record observation control. */ + jobOutput: IJobOutput + } +} + +/** Owns the bare job-output snapshot and per-job observation streams. */ +export class ClientJobOutput extends Service implements IJobOutput { + readonly state: JobOutputSource + private readonly entries = new Map() + + /** + * @param ctx - client root Context. + * @param remote - the Gateway stream factory plus the generated `job` namespace, both resolved by the caller. + * @param model - shared client job-output model. + */ + constructor( + ctx: Context, + private readonly remote: JobObserveRemote, + private readonly model: ClientJobOutputModel, + ) { + super(ctx, 'jobOutput') + this.state = model + ctx.effect(() => () => { + const open = [...this.entries.values()] + this.entries.clear() + for (const entry of open) { + entry.stopped = true + void entry.dispose() + } + }, 'job-controller.client.observations') + } + + observe(sessionId: SessionId | undefined, id: JobId): () => void { + const key = String(id) + const existing = this.entries.get(key) + if (existing !== undefined && !existing.stopped) { + existing.refs += 1 + return this.releaser(key, existing) + } + const entry = this.startObservation(sessionId, id) + this.entries.set(key, entry) + return this.releaser(key, entry) + } + + /** + * Release closures bind the exact entry they were minted for, never the + * map's current occupant: a later `observe()` on the same id may have + * replaced a stopped entry, and decrementing or disposing through the key + * alone would tear down that newer stream's references. + */ + private releaser(key: string, entry: ObservationEntry): () => void { + let released = false + return () => { + if (released) return + released = true + entry.refs -= 1 + if (entry.refs > 0) return + if (this.entries.get(key) === entry) this.entries.delete(key) + entry.stopped = true + void entry.dispose().then(() => { + // Clear the view only while no successor observation holds the key: + // a re-expand inside the dispose round-trip already re-anchored the + // model, and a stale clear would blank its panel for good. + if (this.entries.has(key)) return + // The key is the stringified branded id this releaser was minted for. + this.model.observeStopped(key as JobId) + }) + } + } + + private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry { + const name = `job observation ${String(id)}` + const stream = this.remote.$stream({ + name, + open: (signal) => { + const from = this.model.cursorOf(id) + return this.remote.job.observe( + { + jobId: id, + ...sessionId !== undefined ? { sessionId } : {}, + ...from !== undefined ? { from } : {}, + }, + signal, + ) + }, + // A premature end after the anchor is retryable (a Host reload closes the + // generation); resuming from the cursor loses nothing. An end before the + // anchor is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before settlement`) + : new Error(`${name} ended before its anchor`), + }) + const entry: ObservationEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + const frame = item.value + if (frame.type === 'opened') { + this.model.observeOpened(id, frame) + item.accept() + continue + } + if (frame.type === 'output') { + this.model.observeOutput(id, frame) + continue + } + // Terminal status: leave the loop before the generation end is + // classified, then close the stream for good. + this.model.observeStatus(id, frame) + break + } + } catch (error) { + if (!entry.stopped) this.model.observeFailed(id, error) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } +} diff --git a/packages/api/job-controller/src/index.ts b/packages/api/job-controller/src/index.ts new file mode 100644 index 0000000000..27e85a0ed0 --- /dev/null +++ b/packages/api/job-controller/src/index.ts @@ -0,0 +1,86 @@ +/** + * Host job Remote owner: streams one background job's observation record to + * browsers over the generated `job` namespace. The roster itself rides the + * session control stream (`jobsBySession`, owned by `dsh-api-session-controller`); + * this controller carries only the non-consuming record output. + * @module @deepseek-ai/dsh-api-job-controller + */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-jobs' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { observeJobRecord } from './observe.ts' +import type { JobObserveFrame, JobObserveRequest } from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host job Remote namespace owner. */ + jobController: JobController + } +} + +/** Default coalescing window between record reads, in milliseconds. */ +const DEFAULT_OBSERVE_FLUSH_MS = 100 + +/** Default soft byte budget per output frame. */ +const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024 + +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number +} + +/** Host service backing the generated `ctx.remote.job` namespace. */ +export class JobController extends TypertRemoteService { + static inject = ['agents', 'jobs', 'typert'] + + static Config: z = z.object({ + observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), + observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES), + }) + + private readonly observeFlushMs: number + private readonly observeMaxFrameBytes: number + + /** + * @param ctx - Host context carrying the live Agent registry and the job registry. + * @param config - observation cadence and framing policy. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'jobController', { namespace: 'job' }) + // schemastery (the exported Config schema) has already filled the defaulted + // fields; the assertion records that resolution, not a hidden fallback. + const resolved = config as Required + this.observeFlushMs = resolved.observeFlushMs + this.observeMaxFrameBytes = resolved.observeMaxFrameBytes + } + + /** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ + @Remote({ mode: 'stream' }) + observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable { + const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId) + return observeJobRecord(this.ctx.jobs, request, { + flushMs: this.observeFlushMs, + maxFrameBytes: this.observeMaxFrameBytes, + caller, + }, signal) + } +} + +export default JobController diff --git a/packages/api/session-controller/src/observe-job.ts b/packages/api/job-controller/src/observe.ts similarity index 94% rename from packages/api/session-controller/src/observe-job.ts rename to packages/api/job-controller/src/observe.ts index 795e786455..731f40b2e2 100644 --- a/packages/api/session-controller/src/observe-job.ts +++ b/packages/api/job-controller/src/observe.ts @@ -3,7 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { JobRegistry, JobSnapshot } from '@deepseek-ai/dsh-jobs' -import type { SessionJobWireChunk, SessionObserveJobFrame, SessionObserveJobRequest } from './types.ts' +import type { JobObserveFrame, JobObserveRequest, JobWireChunk } from './types.ts' /** Cadence and framing bounds for one observation generation. */ export interface ObserveJobOptions { @@ -91,16 +91,16 @@ function sleep(ms: number, signal: AbortSignal): Promise { */ export async function* observeJobRecord( registry: JobRegistry, - request: SessionObserveJobRequest, + request: JobObserveRequest, options: ObserveJobOptions, signal: AbortSignal, -): AsyncIterable { +): AsyncIterable { if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) } signal.throwIfAborted() // The brand is nominal typing only; the wire boundary stamps it here rather - // than value-importing the optional registry package's constructor. + // than value-importing the registry package's constructor. const id = String(request.jobId) as JobId const waiter = new OutputWaiter() // Subscribe before the first read so an append between the anchor read and @@ -153,12 +153,12 @@ export async function* observeJobRecord( /** Split one read into frames along the soft per-frame byte budget. */ function* outputFrames( - chunks: readonly SessionJobWireChunk[], + chunks: readonly JobWireChunk[], next: number, lossy: boolean, maxFrameBytes: number, -): Iterable { - let batch: SessionJobWireChunk[] = [] +): Iterable { + let batch: JobWireChunk[] = [] let batchBytes = 0 let flaggedLossy = lossy for (const chunk of chunks) { diff --git a/packages/api/job-controller/src/types.ts b/packages/api/job-controller/src/types.ts new file mode 100644 index 0000000000..98df4d8f65 --- /dev/null +++ b/packages/api/job-controller/src/types.ts @@ -0,0 +1,75 @@ +/** + * Wire types of the generated `job` Remote namespace: the observation request + * and the frames its stream yields. Client-safe: no Host imports. + * @module @deepseek-ai/dsh-api-job-controller/types + */ + +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** + * A job's lifecycle state on the wire. Spelled out rather than imported from + * the registry package so this client-safe face pulls no Host declaration + * merges into browser programs; it is the registry's `JobStatus` union. + */ +export type JobObserveStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + +/** Target of one `job.observe` stream: the job, its owning session, and an optional resume offset. */ +export interface JobObserveRequest { + /** + * Owning session, resolved to the live agent for the fenced record read. + * Omitted for an unowned job, which any caller may observe. + */ + readonly sessionId?: SessionId + readonly jobId: JobId + /** + * Absolute byte offset to resume from (a prior frame's `next`). Omitted, the + * stream starts at the oldest retained byte. + */ + readonly from?: number +} + +/** One retained record chunk on the wire. `channel` widens to string like a job's `kind`. */ +export interface JobWireChunk { + /** Absolute offset of the chunk's first byte. */ + readonly at: number + readonly text: string + readonly channel?: string + /** Bytes immediately before this chunk were lost at the producer or to retention. */ + readonly gapBefore?: true +} + +/** + * Job observation stream frames: one `opened` anchor, then coalesced `output` + * batches, then — once the job has settled and its retained record is + * drained — one terminal `status`, after which the stream closes normally. + * Status rides the same stream as output so settlement can never race a + * still-open output channel. + */ +export type JobObserveFrame = + | { + readonly type: 'opened' + readonly jobId: JobId + /** Offset the first `output` frame continues from. */ + readonly from: number + /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ + readonly earliest: number + /** Total bytes produced at open time. */ + readonly total: number + readonly status: JobObserveStatus + readonly detail?: string + } + | { + readonly type: 'output' + readonly chunks: readonly JobWireChunk[] + /** Offset to resume from after this frame. */ + readonly next: number + /** Bytes between the requested offset and `chunks` were already evicted. */ + readonly lossy?: true + } + | { + readonly type: 'status' + readonly status: JobObserveStatus + readonly detail?: string + readonly finishedAt?: number + } diff --git a/packages/api/job-controller/tests/client-apply.client.spec.ts b/packages/api/job-controller/tests/client-apply.client.spec.ts new file mode 100644 index 0000000000..ebbe67e98e --- /dev/null +++ b/packages/api/job-controller/tests/client-apply.client.spec.ts @@ -0,0 +1,65 @@ +import { Context } from '@deepseek-ai/cordis' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { RemoteStream, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { afterEach, describe, expect, it } from 'vitest' +import * as JobClient from '../src/client/index.ts' +import type { JobObserveFrame } from '../src/types.ts' + +const contexts = new Set() + +afterEach(async () => { + await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() })) + contexts.clear() +}) + +/** Mount the client half over a captured `remote.job` namespace and a real Gateway stream carrier. */ +async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> { + const ctx = new Context() + contexts.add(ctx) + const observeCalls: unknown[] = [] + const connection: ConnectionHandle = { + isLoopback: true, + generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} }, + state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, + rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')) }, + reconnect: () => {}, + registerGenerationSource: () => () => {}, + start: () => ({ stop: () => {} }), + } + const job = { + // The fake stream never yields; opening and releasing must leave no view behind. + observe: (request: unknown) => { + observeCalls.push(request) + return (async function* (): AsyncGenerator {})() + }, + } + ctx.reflect.provide('remote', { + $stream: (options: RemoteStreamOptions) => new RemoteStream(connection, options), + job, + }) + ctx.reflect.provide('remote.job', job) + await ctx.plugin(JobClient) + return { ctx, observeCalls } +} + +async function flush(): Promise { + for (let index = 0; index < 12; index++) await Promise.resolve() +} + +describe('Job Controller Client apply', () => { + it('declares the Remote services it binds', () => { + expect(JobClient.inject).toEqual(['remote', 'remote.job']) + }) + + it('installs ctx.jobOutput over the captured job namespace', async () => { + const { ctx, observeCalls } = await mount() + expect(ctx.jobOutput).toBeDefined() + const stop = ctx.jobOutput.observe('session-1' as SessionId, 'bash-1' as never) + await flush() + expect(observeCalls).toEqual([{ sessionId: 'session-1', jobId: 'bash-1' }]) + stop() + await flush() + expect(ctx.jobOutput.state.getSnapshot().observed).toEqual({}) + }) +}) diff --git a/packages/api/session-controller/tests/job-output.client.spec.ts b/packages/api/job-controller/tests/model.client.spec.ts similarity index 98% rename from packages/api/session-controller/tests/job-output.client.spec.ts rename to packages/api/job-controller/tests/model.client.spec.ts index a4ca2933dc..69260ff3fc 100644 --- a/packages/api/session-controller/tests/job-output.client.spec.ts +++ b/packages/api/job-controller/tests/model.client.spec.ts @@ -2,8 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { ClientJobOutput, ClientJobOutputModel } from '../src/client/job-output.ts' -import type { SessionObserveJobFrame } from '../src/types.ts' +import { ClientJobOutputModel } from '../src/client/model.ts' +import { ClientJobOutput } from '../src/client/service.ts' +import type { JobObserveFrame } from '../src/types.ts' const ID = 'bash-1' as JobId @@ -152,12 +153,12 @@ class FakeStream { deferDispose = false private disposeRelease: (() => void) | undefined private disposePending: Promise | undefined - private readonly frames: SessionObserveJobFrame[] = [] + private readonly frames: JobObserveFrame[] = [] private waiter: (() => void) | undefined private failure: Error | undefined readonly accepts: number[] = [] - push(frame: SessionObserveJobFrame): void { + push(frame: JobObserveFrame): void { this.frames.push(frame) this.waiter?.() } @@ -218,8 +219,8 @@ function bench() { streams.push({ options, stream }) return stream }, - session: { - observeJob: (request: unknown) => { + job: { + observe: (request: unknown) => { observeCalls.push(request) return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } }, diff --git a/packages/api/session-controller/tests/observe-job.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts similarity index 89% rename from packages/api/session-controller/tests/observe-job.host.spec.ts rename to packages/api/job-controller/tests/observe.host.spec.ts index 06a67848ec..5bbb5a8c66 100644 --- a/packages/api/session-controller/tests/observe-job.host.spec.ts +++ b/packages/api/job-controller/tests/observe.host.spec.ts @@ -6,10 +6,11 @@ import { JobId } from '@deepseek-ai/dsh-jobs' import type { JobOutcome, RunningJob } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' -import { observeJobRecord } from '../src/observe-job.ts' -import type { ObserveJobOptions } from '../src/observe-job.ts' -import type { SessionObserveJobFrame } from '../src/types.ts' -import { createSessionTestController } from './test-remote.ts' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { JobController } from '../src/index.ts' +import { observeJobRecord } from '../src/observe.ts' +import type { ObserveJobOptions } from '../src/observe.ts' +import type { JobObserveFrame } from '../src/types.ts' async function harness() { const ctx = new Context() @@ -88,7 +89,7 @@ const OBSERVE: Omit = { flushMs: 5, maxFrameBytes: describe('observeJobRecord', () => { function observed(ctx: Context, request: { jobId: JobId; from?: number }, caller?: Agent) { const abort = new AbortController() - const frames: SessionObserveJobFrame[] = [] + const frames: JobObserveFrame[] = [] const stream = observeJobRecord(ctx.jobs, request, { ...OBSERVE, caller }, abort.signal) const done = (async () => { for await (const frame of stream) frames.push(frame) @@ -142,7 +143,7 @@ describe('observeJobRecord', () => { job.append('y'.repeat(30)) await job.settle({ status: 'completed' }) const abort = new AbortController() - const frames = await collect( + const frames = await collect( observeJobRecord(ctx.jobs, { jobId: job.id }, { flushMs: 5, maxFrameBytes: 30, @@ -165,7 +166,7 @@ describe('observeJobRecord', () => { job.append('c'.repeat(40)) await job.settle({ status: 'completed' }) const abort = new AbortController() - const frames = await collect( + const frames = await collect( observeJobRecord(ctx.jobs, { jobId: job.id, from: 0 }, { flushMs: 5, maxFrameBytes: 40, @@ -278,28 +279,30 @@ describe('observeJobRecord', () => { }) }) -describe('SessionController.observeJob', () => { - const defaults = { - defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), - cwd: '/tmp', - } - - it('refuses observation without a job registry', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - const controller = createSessionTestController(ctx, defaults) - expect(() => controller.observeJob({ jobId: JobId('bash-1') }, new AbortController().signal)) - .toThrow(/job observation unavailable/) - }) - - it('resolves the fenced-read caller from the request session', async () => { +describe('JobController.observe', () => { + async function controllerHarness() { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) + await ctx.plugin(TypertRegistry) ctx.jobs.attachController('observe-job-test') - const controller = createSessionTestController(ctx, defaults) + await ctx.plugin(JobController, {}) + return ctx + } + + it('waits for the job registry instead of serving without one', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(TypertRegistry) + ctx.plugin(JobController, {}) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(ctx.get('jobController')).toBeUndefined() + }) + + it('resolves the fenced-read caller from the request session', async () => { + const ctx = await controllerHarness() const session = ctx.sessions.create(SessionId('observing-session')) const owner = { id: session.id, @@ -314,8 +317,8 @@ describe('SessionController.observeJob', () => { await job.settle({ status: 'completed' }) const abort = new AbortController() - const frames = await collect( - controller.observeJob({ sessionId: session.id, jobId: job.id }, abort.signal), + const frames = await collect( + ctx.jobController.observe({ sessionId: session.id, jobId: job.id }, abort.signal), 3, abort, ) @@ -323,19 +326,14 @@ describe('SessionController.observeJob', () => { }) it('observes an unowned job without a request session', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - ctx.jobs.attachController('observe-job-test') - const controller = createSessionTestController(ctx, defaults) + const ctx = await controllerHarness() const job = startRecordJob(ctx, { label: 'unowned run' }) job.append('open access') await job.settle({ status: 'completed' }) const abort = new AbortController() - const frames = await collect( - controller.observeJob({ jobId: job.id }, abort.signal), + const frames = await collect( + ctx.jobController.observe({ jobId: job.id }, abort.signal), 3, abort, ) diff --git a/packages/api/job-controller/tsconfig.client.json b/packages/api/job-controller/tsconfig.client.json new file mode 100644 index 0000000000..34a2202060 --- /dev/null +++ b/packages/api/job-controller/tsconfig.client.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/model.ts", + "src/client/service.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../../client/connection/tsconfig.client.json" }, + { "path": "../../client/store" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/job-controller/tsconfig.host.json b/packages/api/job-controller/tsconfig.host.json new file mode 100644 index 0000000000..96de4dede2 --- /dev/null +++ b/packages/api/job-controller/tsconfig.host.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/observe.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../core/agent" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/job-controller/tsconfig.json b/packages/api/job-controller/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/job-controller/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/job-controller/tsdown.config.ts b/packages/api/job-controller/tsdown.config.ts new file mode 100644 index 0000000000..0391bc61bf --- /dev/null +++ b/packages/api/job-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-job-controller', + ['lib/types/index.js'], + { hostPhase: true }, +) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index a60099926e..6aabaa7039 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -62,8 +62,11 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-job-controller": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", @@ -72,14 +75,12 @@ "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-user-questions": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/dsh-user-questions": "workspace:^" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index e7fc7757e9..ce08f3dbe3 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -12,6 +12,7 @@ import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' +import jobRemote from '@deepseek-ai/dsh-api-job-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -29,6 +30,8 @@ export type {} from '@deepseek-ai/dsh-subagent/remote' export type * from '@deepseek-ai/dsh-subagent/client' export type {} from '@deepseek-ai/dsh-api-session-controller/remote' export type * from '@deepseek-ai/dsh-api-session-controller/types' +export type {} from '@deepseek-ai/dsh-api-job-controller/remote' +export type * from '@deepseek-ai/dsh-api-job-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' @@ -146,7 +149,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, - subagentsRemote, sessionRemote, workspaceRemote, + subagentsRemote, sessionRemote, jobRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 56f664d499..ddd32b2af2 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -22,7 +22,6 @@ }, { "path": "../../credentials/credentials" - }, { "path": "../../context/file-reference" @@ -63,6 +62,9 @@ { "path": "../../interaction/user-questions" }, + { + "path": "../job-controller/tsconfig.client.json" + }, { "path": "../session-controller/tsconfig.client.json" }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 8bfe2650e7..fa85bc3ebe 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 5f1e78b8d95d3fce99c5a533f05ccc6da1455a14 -README.zh.md: f4276b4f8940d66fbf6e76f3b4b49e55b51179d1 +README.md: 4f94607e70a5b963fa50d7a497afbcc97d273cb9 +README.zh.md: 455769f77ce8ad304949624de938e56579024145 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 5f1e78b8d9..4f94607e70 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. -A `SessionJob` row carries `outputTotal` exactly when the job declared an observation record, and `session.observeJob` streams that record from an absolute byte offset — one `opened` anchor, coalesced `output` frames, then one terminal `status` — with the fenced read resolved from the request's session. Record reads are non-consuming: the model-facing cursor and completion-notice state never observe them. The Client adapter exposes `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views a task-list UI renders; observers share one stream per job and resume from their model cursor across reconnects. +A `SessionJob` row carries `outputTotal` exactly when the job declared an observation record; streaming that record is the job controller's `job.observe` (see [`dsh-api-job-controller`](../job-controller/README.md)), while this package keeps the roster on the control stream. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. @@ -43,8 +43,6 @@ The Session object also carries local submission echoes: `session.beginSubmissio | `coldBlankProbeMaxEvents` | `16` | Maximum stat-reported event count of a cold Session eligible for blankness verification; `0` disables the event-count gate | | `coldBlankProbeMaxBytes` | `1,024` | Maximum stat-reported artifact byte size of a cold Session eligible for blankness verification when the backend offers no event count; `0` disables the byte-size gate | | `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener | -| `observeFlushMs` | `100` | Coalescing window after new record output before a job-observation read, in milliseconds | -| `observeMaxFrameBytes` | `65,536` | Soft byte budget per job-observation output frame; one larger chunk ships whole | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index f4276b4f89..455769f77c 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 -`SessionJob` 行恰在该 job 声明了观测 record 时携带 `outputTotal`,`session.observeJob` 从绝对字节偏移流式发送该 record——一个 `opened` 锚帧、聚合的 `output` 帧、最后一个终态 `status` 帧——围栏读取者由请求的 session 解析。record 读取是非消耗的:模型侧游标与完成通知状态永远观察不到它们。Client adapter 提供 `ctx.jobOutput`:按 job 引用计数的观测服务,任务列表 UI 渲染其累积视图;同一 job 的观察者共享一条流,重连时从模型游标续读。 +`SessionJob` 行恰在该 job 声明了观测 record 时携带 `outputTotal`;流式发送该 record 的是 job 控制器的 `job.observe`(见 [`dsh-api-job-controller`](../job-controller/README.zh.md)),本包只在控制流上保留名册。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 @@ -43,8 +43,6 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 | `coldBlankProbeMaxEvents` | `16` | stat 报告的事件数不超过该值的冷 Session 才可进行空白状态验证;`0` 禁用事件数门槛 | | `coldBlankProbeMaxBytes` | `1,024` | 后端不提供事件数时,stat 报告的工件字节数不超过该值的冷 Session 才可进行空白状态验证;`0` 禁用字节数门槛 | | `nativeOpen` | 平台探测 | 是否能把 Session 工作区路径交给原生桌面打开器 | -| `observeFlushMs` | `100` | 新 record 输出到一次 job 观测读取之间的聚合窗口,毫秒 | -| `observeMaxFrameBytes` | `65,536` | 每个 job 观测输出帧的软字节预算;更大的单块整块发送 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。 diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 5fc9570a55..4bad2bb8c2 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -3,7 +3,6 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent/types' import { createSessionControlStream } from './transport.ts' -import { ClientJobOutput, ClientJobOutputModel } from './job-output.ts' import { ClientSessions } from './sessions/service.ts' import type { SessionRemotes } from './sessions/remotes.ts' import type {} from '../remote-events.ts' @@ -24,8 +23,6 @@ export type { } from './transport.ts' export { createScope, scopeOf } from './scope.ts' export type { AgentContext, AgentScopeHandle } from './scope.ts' -export { ClientJobOutput, ClientJobOutputModel } from './job-output.ts' -export type { IJobOutput, JobOutputSnapshot, JobOutputSource, ObservedJob } from './job-output.ts' export { SessionCreateError, SessionForkError } from './sessions/service.ts' export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' export type { @@ -92,14 +89,6 @@ export const inject = [ export function apply(ctx: Context): void { const remotes = ctx.remote as unknown as SessionRemotes const sessions = new ClientSessions(ctx, remotes) - // Resolve the session namespace to a concrete value while this plugin's - // context is current: observation (re)opens run on caller stacks (a React - // event, a carrier retry) whose dynamic context has not declared - // `remote.session`, and a property access there fails the inject check. - new ClientJobOutput(ctx, { - $stream: options => remotes.$stream(options), - session: remotes.session, - }, new ClientJobOutputModel()) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) ctx.remote.$on('api-session/status', (sessionId, running) => { diff --git a/packages/api/session-controller/src/client/job-output.ts b/packages/api/session-controller/src/client/job-output.ts deleted file mode 100644 index 933c104497..0000000000 --- a/packages/api/session-controller/src/client/job-output.ts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * React-free client job-record observation: per-job accumulated output views - * over the `session.observeJob` stream, reference-counted per job so - * overlapping panels share one stream. The roster itself rides the session - * control stream (`jobsBySession`); this module carries only the output. - * @module @deepseek-ai/dsh-api-session-controller/client/job-output - */ - -import { Service, type Context } from '@deepseek-ai/cordis' -import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' -import { notifySubscribers } from '@deepseek-ai/dsh-client-store' -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SessionJob, SessionObserveJobFrame } from '../types.ts' -import type { SessionRemote } from './transport.ts' - -/** Bounded per-job render tail, in UTF-16 code units. */ -const RENDER_TAIL_LIMIT = 128 * 1024 - -/** One observed job's live view state. */ -export interface ObservedJob { - readonly jobId: JobId - /** Accumulated output tail, bounded to the render limit. */ - readonly text: string - /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ - readonly gapBefore: boolean - /** Latest status delivered on the observation stream. */ - readonly status: SessionJob['status'] - readonly detail?: string - /** True while the observation stream is open and the job has not settled. */ - readonly streaming: boolean - /** Terminal observation failure, when the stream ended abnormally. */ - readonly error?: string -} - -/** Immutable client job-output state. */ -export interface JobOutputSnapshot { - /** Live observation state keyed by job id. */ - readonly observed: Readonly> -} - -/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */ -interface ObservedState { - view: ObservedJob - /** Resume offset for the next generation's `from`. */ - cursor: number | undefined -} - -/** - * Owns per-job observation state. Pure data plus subscriptions — transport - * wiring stays in the client service, UI stays in slot components. - */ -export class ClientJobOutputModel { - private readonly observedStates = new Map() - private readonly listeners = new Set<() => void>() - private snapshotCache: JobOutputSnapshot = { observed: {} } - private snapshotDirty = false - - /** - * Read the identity-stable current snapshot. - * @returns the cached snapshot, rebuilt only after a change. - */ - getSnapshot(): JobOutputSnapshot { - if (this.snapshotDirty) { - const observed: Record = {} - for (const [id, state] of this.observedStates) observed[id] = state.view - this.snapshotCache = { observed } - this.snapshotDirty = false - } - return this.snapshotCache - } - - /** - * Subscribe to snapshot changes. - * @param listener - invalidation callback. - * @returns unsubscribe function. - */ - subscribe(listener: () => void): () => void { - this.listeners.add(listener) - return () => { this.listeners.delete(listener) } - } - - /** - * The resume offset for one job's next observation generation. - * @param id - observed job. - * @returns the last accepted `next`, or undefined for a fresh observation. - */ - cursorOf(id: JobId): number | undefined { - return this.observedStates.get(String(id))?.cursor - } - - /** - * Install or reset observation state when a generation's anchor arrives. - * @param id - observed job. - * @param frame - the generation's `opened` anchor. - */ - observeOpened(id: JobId, frame: Extract): void { - const existing = this.observedStates.get(String(id)) - // A fresh view anchored past offset zero starts after an evicted head - // (fresh observations anchor at the registry's earliest retained byte), so - // it owes the same gap mark a live observer earned from lossy reads. A - // resume that already accumulated text keeps its recorded gap state. - const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 - const view: ObservedJob = { - jobId: id, - text: existing?.view.text ?? '', - gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, - status: frame.status, - ...frame.detail !== undefined ? { detail: frame.detail } : {}, - streaming: true, - } - this.observedStates.set(String(id), { view, cursor: frame.from }) - this.changed() - } - - /** - * Append one output frame's chunks to the bounded render tail. - * @param id - observed job. - * @param frame - a coalesced `output` frame. - */ - observeOutput(id: JobId, frame: Extract): void { - const state = this.observedStates.get(String(id)) - /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ - if (state === undefined) return - let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') - let gapBefore = state.view.gapBefore || frame.lossy === true - || frame.chunks.some(chunk => chunk.gapBefore === true) - if (text.length > RENDER_TAIL_LIMIT) { - let cut = text.length - RENDER_TAIL_LIMIT - // Never split a surrogate pair at the render bound. - const unit = text.charCodeAt(cut) - if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 - text = text.slice(cut) - gapBefore = true - } - state.view = { ...state.view, text, gapBefore } - state.cursor = frame.next - this.changed() - } - - /** - * Record the terminal status delivered at the end of the observation stream. - * @param id - observed job. - * @param frame - the terminal `status` frame. - */ - observeStatus(id: JobId, frame: Extract): void { - const state = this.observedStates.get(String(id)) - /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ - if (state === undefined) return - state.view = { - ...state.view, - status: frame.status, - ...frame.detail !== undefined ? { detail: frame.detail } : {}, - streaming: false, - } - this.changed() - } - - /** - * Record a terminal observation failure. - * @param id - observed job. - * @param error - the stream's terminal failure. - */ - observeFailed(id: JobId, error: unknown): void { - const state = this.observedStates.get(String(id)) - if (state === undefined) return - state.view = { ...state.view, streaming: false, error: String(error) } - this.changed() - } - - /** - * Drop observation state after the last observer stops. - * @param id - the no-longer-observed job. - */ - observeStopped(id: JobId): void { - if (!this.observedStates.delete(String(id))) return - this.changed() - } - - private changed(): void { - this.snapshotDirty = true - notifySubscribers(this.listeners, 'job output') - } -} - -/** Bare observable source for the client job-output snapshot. */ -export interface JobOutputSource { - /** Read the identity-stable current snapshot. */ - getSnapshot(): JobOutputSnapshot - /** - * Subscribe to snapshot changes. - * @param listener - invalidation callback. - * @returns unsubscribe function. - */ - subscribe(listener: () => void): () => void -} - -/** The job-output client service face. */ -export interface IJobOutput { - /** Per-job observation state. */ - readonly state: JobOutputSource - /** - * Start observing one job's live record; reference-counted, so two viewers - * of the same job share one stream. - * @param sessionId - owning session used for the fenced read; undefined for an unowned job. - * @param id - job to observe. - * @returns stop function releasing this observer's reference. - */ - observe(sessionId: SessionId | undefined, id: JobId): () => void -} - -/** One reference-counted observation stream. */ -interface ObservationEntry { - refs: number - stopped: boolean - dispose: () => Promise -} - -/** Remote face the observation runner drives. */ -type JobObserveRemote = Pick & { readonly session: Pick } - -declare module '@deepseek-ai/cordis' { - interface Context { - /** React-free client job-record observation control. */ - jobOutput: IJobOutput - } -} - -/** Owns the bare job-output snapshot and per-job observation streams. */ -export class ClientJobOutput extends Service implements IJobOutput { - readonly state: JobOutputSource - private readonly entries = new Map() - - /** - * @param ctx - client root Context. - * @param remote - generated session namespace plus the Gateway stream factory. - * @param model - shared client job-output model. - */ - constructor( - ctx: Context, - private readonly remote: JobObserveRemote, - private readonly model: ClientJobOutputModel, - ) { - super(ctx, 'jobOutput') - this.state = model - ctx.effect(() => () => { - const open = [...this.entries.values()] - this.entries.clear() - for (const entry of open) { - entry.stopped = true - void entry.dispose() - } - }, 'session-controller.client.observations') - } - - observe(sessionId: SessionId | undefined, id: JobId): () => void { - const key = String(id) - const existing = this.entries.get(key) - if (existing !== undefined && !existing.stopped) { - existing.refs += 1 - return this.releaser(key, existing) - } - const entry = this.startObservation(sessionId, id) - this.entries.set(key, entry) - return this.releaser(key, entry) - } - - /** - * Release closures bind the exact entry they were minted for, never the - * map's current occupant: a later `observe()` on the same id may have - * replaced a stopped entry, and decrementing or disposing through the key - * alone would tear down that newer stream's references. - */ - private releaser(key: string, entry: ObservationEntry): () => void { - let released = false - return () => { - if (released) return - released = true - entry.refs -= 1 - if (entry.refs > 0) return - if (this.entries.get(key) === entry) this.entries.delete(key) - entry.stopped = true - void entry.dispose().then(() => { - // Clear the view only while no successor observation holds the key: - // a re-expand inside the dispose round-trip already re-anchored the - // model, and a stale clear would blank its panel for good. - if (this.entries.has(key)) return - // The key is the stringified branded id this releaser was minted for. - this.model.observeStopped(key as JobId) - }) - } - } - - private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry { - const name = `job observation ${String(id)}` - const stream = this.remote.$stream({ - name, - open: (signal) => { - const from = this.model.cursorOf(id) - return this.remote.session.observeJob( - { - jobId: id, - ...sessionId !== undefined ? { sessionId } : {}, - ...from !== undefined ? { from } : {}, - }, - signal, - ) - }, - // A premature end after the anchor is retryable (a Host reload closes the - // generation); resuming from the cursor loses nothing. An end before the - // anchor is terminal. - ended: accepted => accepted - ? new RemoteStreamCarrierError(`${name} ended before settlement`) - : new Error(`${name} ended before its anchor`), - }) - const entry: ObservationEntry = { - refs: 1, - stopped: false, - dispose: () => stream.dispose(), - } - void (async () => { - try { - for await (const item of stream) { - const frame = item.value - if (frame.type === 'opened') { - this.model.observeOpened(id, frame) - item.accept() - continue - } - if (frame.type === 'output') { - this.model.observeOutput(id, frame) - continue - } - // Terminal status: leave the loop before the generation end is - // classified, then close the stream for good. - this.model.observeStatus(id, frame) - break - } - } catch (error) { - if (!entry.stopped) this.model.observeFailed(id, error) - } finally { - entry.stopped = true - void entry.dispose() - } - })() - return entry - } -} diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b6dd8e08b5..1f8718e62f 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -2,7 +2,6 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type {} from '@deepseek-ai/dsh-jobs' import { errorChain } from '@deepseek-ai/dsh-llm' import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -17,7 +16,6 @@ import { import { SessionCommandController } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' -import { observeJobRecord } from './observe-job.ts' import { SessionFileReferences } from './file-references.ts' import { ApiSessionList, @@ -42,8 +40,6 @@ import type { SessionForkValue, SessionListRequest, SessionListValue, - SessionObserveJobFrame, - SessionObserveJobRequest, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionPage, @@ -72,12 +68,6 @@ declare module '@deepseek-ai/cordis' { } } -/** Default coalescing window between job-observation reads, in milliseconds. */ -const DEFAULT_OBSERVE_FLUSH_MS = 100 - -/** Default soft byte budget per job-observation output frame. */ -const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024 - /** Session Controller deployment policy. */ export interface Config { /** Maximum stat-reported event count eligible for one full cold projection observation; `0` disables the event-count gate. */ @@ -86,10 +76,6 @@ export interface Config { readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean - /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ - readonly observeFlushMs?: number - /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ - readonly observeMaxFrameBytes?: number } /** Host integrations replaceable by direct unit tests. */ @@ -118,8 +104,6 @@ export class SessionController extends TypertRemoteService { coldBlankProbeMaxEvents: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS), coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), nativeOpen: z.boolean(), - observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), - observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES), }) private readonly agents: ApiSessionAgentController @@ -130,8 +114,6 @@ export class SessionController extends TypertRemoteService { private readonly openPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean private readonly promotions = new Set>() - private readonly observeFlushMs: number - private readonly observeMaxFrameBytes: number /** * @param ctx - Host context containing the Session capability assembly. @@ -157,8 +139,6 @@ export class SessionController extends TypertRemoteService { this.openPath = internals.openPath ?? openNativePath this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) - this.observeFlushMs = config.observeFlushMs ?? DEFAULT_OBSERVE_FLUSH_MS - this.observeMaxFrameBytes = config.observeMaxFrameBytes ?? DEFAULT_OBSERVE_MAX_FRAME_BYTES ctx.plugin(SessionFileReferences) ctx.plugin(SessionSkillCatalog) @@ -419,29 +399,6 @@ export class SessionController extends TypertRemoteService { return this.controlState.control(signal) } - /** - * Stream one job's retained record output from an absolute byte offset, - * then its terminal status once settled and drained. Non-consuming: the - * model-facing cursor and notice state never observe these reads. The - * request's session resolves the fenced-read caller; the registry rejects a - * job the session does not own, a job without a record, or an unknown job. - * @param request - target job, owning session, and optional resume offset. - * @param signal - cancellation owned by the Remote stream carrier. - * @returns anchor, coalesced output frames, and the terminal status. - */ - @Remote({ mode: 'stream' }) - observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable { - const registry = this.ctx.get('jobs') - if (registry === undefined) { - throw new Error('job observation unavailable: no job registry is loaded (load @deepseek-ai/dsh-jobs-local)') - } - const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId) - return observeJobRecord(registry, request, { - flushMs: this.observeFlushMs, - maxFrameBytes: this.observeMaxFrameBytes, - caller, - }, signal) - } } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index b02c69ede8..c5a6caeacb 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -491,7 +491,7 @@ export interface SessionJob { /** * Total UTF-8 bytes the job's observation record holds so far. Present * exactly when the job declared a record — the row is observable through - * `session.observeJob`. + * the job controller's `job.observe` stream. */ readonly outputTotal?: number } @@ -518,62 +518,7 @@ export type SessionControlFrame = | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] } | ({ readonly type: 'projection' } & SessionProjectionUpdate) -/** Opens one observation stream over a job's retained record output. */ -export interface SessionObserveJobRequest { - /** Owning session used for the fenced read; omitted for an unowned job. */ - readonly sessionId?: SessionId - readonly jobId: JobId - /** - * Absolute byte offset to resume from — a previous frame's `next`. Omitted - * starts at the oldest retained byte. - */ - readonly from?: number -} -/** One retained record chunk on the wire. `channel` widens to string like `kind`. */ -export interface SessionJobWireChunk { - /** Absolute offset of the chunk's first byte. */ - readonly at: number - readonly text: string - readonly channel?: string - /** Bytes immediately before this chunk were lost at the producer or to retention. */ - readonly gapBefore?: true -} - -/** - * Job observation stream frames: one `opened` anchor, then coalesced `output` - * batches, then — once the job has settled and its retained record is - * drained — one terminal `status`, after which the stream closes normally. - * Status rides the same stream as output so settlement can never race a - * still-open output channel. - */ -export type SessionObserveJobFrame = - | { - readonly type: 'opened' - readonly jobId: JobId - /** Offset the first `output` frame continues from. */ - readonly from: number - /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ - readonly earliest: number - /** Total bytes produced at open time. */ - readonly total: number - readonly status: SessionJob['status'] - readonly detail?: string - } - | { - readonly type: 'output' - readonly chunks: readonly SessionJobWireChunk[] - /** Offset to resume from after this frame. */ - readonly next: number - /** Bytes between the requested offset and `chunks` were already evicted. */ - readonly lossy?: true - } - | { - readonly type: 'status' - readonly status: SessionJob['status'] - readonly detail?: string - readonly finishedAt?: number - } declare module '@deepseek-ai/cordis' { interface Events { diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index b3fd854a68..07f88b1a4f 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -106,18 +106,6 @@ async function flush(): Promise { } describe('Session Controller Client apply', () => { - it('installs ctx.jobOutput over the captured session namespace', async () => { - const bench = await mount() - expect(bench.ctx.jobOutput).toBeDefined() - // The fake observeJob never yields; opening and releasing must leave no - // view and no error behind. - const stop = bench.ctx.jobOutput.observe(sid('session-1'), 'bash-1' as never) - await flush() - stop() - await flush() - expect(bench.ctx.jobOutput.state.getSnapshot().observed).toEqual({}) - }) - it('routes Session Remote Events and connection generations into the object layer', async () => { const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected') const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError') diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index e67650d4a2..e3ea48f783 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -10,7 +10,7 @@ import type { import type { SessionAddress, SessionControlBaseline, - SessionControlFrame, SessionObserveJobFrame, + SessionControlFrame, SessionFollowFrame, SessionFollowRequest, SessionPage, @@ -235,9 +235,6 @@ export class FakeApiClient { page: request => this.page(request), follow: (request, signal) => this.openFollow(request, signal), control: signal => this.openControl(signal), - // Job-record observation never yields here; the stream consumer is - // exercised directly by job-output.client.spec. - observeJob: () => (async function* (): AsyncGenerator {})(), }, subagents: { list: parentSessionId => this.record( diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index be09dd8d80..1b5859b509 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -40,8 +40,6 @@ import type { SessionFollowRequest, SessionListRequest, SessionListValue, - SessionObserveJobFrame, - SessionObserveJobRequest, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionPage, @@ -79,7 +77,6 @@ export interface TestSessionRemote { page(request: SessionPageRequest, signal?: AbortSignal): Promise> follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable control(signal?: AbortSignal): AsyncIterable - observeJob(request: SessionObserveJobRequest, signal?: AbortSignal): AsyncIterable } /** Dependencies and policy supplied by a Session Controller unit harness. */ @@ -332,6 +329,5 @@ export function createSessionTestRemote( ), follow: (request, signal = new AbortController().signal) => direct.follow(request, signal), control: (signal = new AbortController().signal) => direct.control(signal), - observeJob: (request, signal = new AbortController().signal) => direct.observeJob(request, signal), } } diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 086ab4a66f..d13a698f67 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -17,34 +17,83 @@ "src/history.ts", "src/list.ts", "src/model-selection-projection.ts", - "src/observe-job.ts", "src/skill-catalog.ts" ], "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../core/agent" }, - { "path": "../../core/agent-default-model" }, - { "path": "../../core/scope" }, - { "path": "../../core/session" }, - { "path": "../../context/file-reference" }, - { "path": "../../attachment/attachment" }, - { "path": "../../interaction/permission-presets" }, - { "path": "../../jobs/jobs" }, - { "path": "../../llm/llm" }, - { "path": "../../util/deque" }, - { "path": "../../util/native-command" }, - { "path": "../../preset/agent-presets" }, - { "path": "../../session/session-persistence" }, - { "path": "../../session/session-projection" }, - { "path": "../../session/session-projection-cache" }, - { "path": "../../session/session-title" }, - { "path": "../../session-query/session-query" }, - { "path": "../../skill/skill" }, - { "path": "../../subagent/subagent" }, - { "path": "../../util/time" }, - { "path": "../../typert/protocol" }, - { "path": "../../typert/registry" }, - { "path": "../../workspace/workspace" } + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/agent-default-model" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../core/session" + }, + { + "path": "../../context/file-reference" + }, + { + "path": "../../attachment/attachment" + }, + { + "path": "../../interaction/permission-presets" + }, + { + "path": "../../jobs/jobs" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../util/deque" + }, + { + "path": "../../util/native-command" + }, + { + "path": "../../preset/agent-presets" + }, + { + "path": "../../session/session-persistence" + }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../session/session-projection-cache" + }, + { + "path": "../../session/session-title" + }, + { + "path": "../../session-query/session-query" + }, + { + "path": "../../skill/skill" + }, + { + "path": "../../subagent/subagent" + }, + { + "path": "../../util/time" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../typert/registry" + }, + { + "path": "../../workspace/workspace" + } ] } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index ef7539a7be..e7c42d038a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -91,6 +91,11 @@ - id: session-controller name: '@deepseek-ai/dsh-api-session-controller' + # One background job's observation record streamed over Typert Remote; + # the roster rides the session control stream above. + - id: job-controller + name: '@deepseek-ai/dsh-api-job-controller' + # Configuration-surface reads and writes over Typert Remote. Each method # reports an actionable error when its settings-domain provider is absent. - id: settings-controller diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 63f32d737e..5d8ce4524d 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -40,73 +40,74 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-api-job-controller": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-settings-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", - "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-approval": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-brand-official": "workspace:^", - "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", - "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-reference": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-schedule": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", - "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-reference": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", - "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", - "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", + "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", + "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-web-frontend": "workspace:^", - "@deepseek-ai/dsh-host-frontend-static": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-log-export": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-session-turn-outline": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-settings-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0", diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 12557c1c73..eb2ed7cf83 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -2,8 +2,8 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { - ConversationLocationDataStore, ConversationTurnDataMap, - MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, + CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap, + MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, ToolCallBlock, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, @@ -16,8 +16,7 @@ import type { createChatStore } from '../stores.ts' import type { ToolCallId, SelectionTarget } from './store.ts' import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from './chat-nodes.ts' import type { - ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation, CommandNode, - CompactionSummaryNode, ToolCallBlock, + ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation, } from './snapshot.ts' import type { TurnProcessSpec } from './turn-process.ts' import type { TranscriptViewMode } from '../../chat-settings.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 6fa7671da6..64d74f28d8 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -1,15 +1,14 @@ import type { Context } from '@deepseek-ai/cordis' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type { - ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder, - ConversationViewDefinition, + ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder, + ConversationViewDefinition, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' import type { ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot, - ChatTurnNavigationIndex, ChatTurnProcessPresentation, ConversationNode, LegacyConversationSlice, - PartialAssistant, RunningToolCall, TurnNavigationItem, + ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem, } from '../contract/snapshot.ts' import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' import { sessionRecallLabels } from './event-projection.ts' diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index b12c1d3926..fe508b8379 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-jobs/README.md -README.md: 51e6916ed4a72fb8f618154bbd2ec156ad0af2bc -README.zh.md: f21e097e7860def07663376bf5de0f9d565a34c0 +README.md: a0ecc137ab1eb57aeaea9ccdfdbaf898794de2fe +README.zh.md: 744450c480b4d26c0558d992904817174ca2ed73 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index 51e6916ed4..a0ecc137ab 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -33,7 +33,7 @@ The session control stream's `jobsBySession` mirror is the single roster: each ` ### The expanded panel -Expanding an observable row opens that job's record observation stream from `ctx.jobOutput` into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel. +Expanding an observable row opens that job's record observation stream from `ctx.jobOutput` (installed by `dsh-api-job-controller`) into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel. ----- @@ -58,7 +58,8 @@ One slot entry in the header actions band (after the preset label, before the su ## Further Exploration -- [`dsh-api-session-controller`](../../api/session-controller/README.md) — the `jobsBySession` mirror and the `session.observeJob` stream behind the panel. +- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.observe` stream and the `ctx.jobOutput` service behind the panel. +- [`dsh-api-session-controller`](../../api/session-controller/README.md) — the `jobsBySession` roster mirror the rows come from. - [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the record semantics. - [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index f21e097e78..744450c480 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -33,7 +33,7 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo ### 展开的面板 -展开可观察的行会从 `ctx.jobOutput` 打开该 job 的 record 观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。 +展开可观察的行会从 `ctx.jobOutput`(由 `dsh-api-job-controller` 安装)打开该 job 的 record 观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。 ----- @@ -58,7 +58,8 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo ## 进一步探索 -- [`dsh-api-session-controller`](../../api/session-controller/README.zh.md) —— 面板背后的 `jobsBySession` 镜像与 `session.observeJob` 流。 +- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 面板背后的 `job.observe` 流与 `ctx.jobOutput` 服务。 +- [`dsh-api-session-controller`](../../api/session-controller/README.zh.md) —— 行所来自的 `jobsBySession` 名册镜像。 - [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有 record 语义的注册表契约。 - [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md) —— 面板所配置的 `TerminalBlock` 表面。 diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 885effb30c..fb85ed298d 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-job-controller": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", @@ -55,6 +56,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-job-controller", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index edac6497f6..87acb18a32 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client' +import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index 164b6f6e3c..209cec1f23 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -8,6 +8,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import { JobListAction } from './JobListAction.tsx' import type { JobListInjected } from './JobListAction.tsx' +import type {} from '@deepseek-ai/dsh-api-job-controller/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index 15bd8ec4a9..9d659ca2ae 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client' +import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' diff --git a/packages/client/ui-jobs/tsconfig.json b/packages/client/ui-jobs/tsconfig.json index 0e66aa4958..38756e4d88 100644 --- a/packages/client/ui-jobs/tsconfig.json +++ b/packages/client/ui-jobs/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/job-controller/tsconfig.client.json" + }, { "path": "../../api/session-controller/tsconfig.client.json" }, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index fa02cba58f..0daf030fdd 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:228', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:227', }, { key: 'conversation.chat.commandview', @@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:216', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:215', }, { key: 'conversation.chat.node', @@ -314,7 +314,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:197', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:196', }, { key: 'conversation.chat.turnTail', @@ -359,7 +359,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:222', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:221', }, { key: 'conversation.composer', @@ -533,7 +533,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:234', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:233', }, { key: 'conversation.hero.agentPreset', @@ -1011,7 +1011,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:210', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:209', }, { key: 'conversation.session', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 2d81832d71..9de2adb4c9 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1033,6 +1033,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'jobController', + summary: 'Host service backing the generated `ctx.remote.job` namespace.', + description: 'Host service backing the generated `ctx.remote.job` namespace.', + methods: [ + { + signature: '@Remote({ mode: \'stream\' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable', + description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.', + parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], + returns: 'anchor, coalesced output frames, and the terminal status.', + }, + ], + }, { key: 'jobs', summary: 'Abstract background job registry.', @@ -1455,12 +1468,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'one complete baseline followed by live replacement frames.', }, - { - signature: '@Remote({ mode: \'stream\' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable', - description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.', - parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], - returns: 'anchor, coalesced output frames, and the terminal status.', - }, ], }, { @@ -4190,6 +4197,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JobKindMap', declaration: 'export interface JobKindMap {\n bash: \'bash\';\n subagent: \'subagent\';\n}', }, + { + name: 'JobObserveFrame', + declaration: 'export type JobObserveFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: JobObserveStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly JobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: JobObserveStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};', + }, + { + name: 'JobObserveRequest', + declaration: 'export interface JobObserveRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}', + }, + { + name: 'JobObserveStatus', + declaration: 'export type JobObserveStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';', + }, { name: 'JobOutcome', declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}', @@ -4226,6 +4245,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JobStatus', declaration: 'export type JobStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';', }, + { + name: 'JobWireChunk', + declaration: 'export interface JobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', + }, { name: 'JsonSchemaNode', declaration: 'export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n}', @@ -4966,10 +4989,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionJob', declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal?: number;\n}', }, - { - name: 'SessionJobWireChunk', - declaration: 'export interface SessionJobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', - }, { name: 'SessionLineageNode', declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}', @@ -5002,14 +5021,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionObservationOptions', declaration: 'export interface SessionObservationOptions {\n readonly signal?: AbortSignal;\n readonly projectionMode?: \'all\' | \'none\';\n}', }, - { - name: 'SessionObserveJobFrame', - declaration: 'export type SessionObserveJobFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly SessionJobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n readonly finishedAt?: number;\n};', - }, - { - name: 'SessionObserveJobRequest', - declaration: 'export interface SessionObserveJobRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}', - }, { name: 'SessionOpenWorkspacePathRequest', declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly path: string;\n}', diff --git a/packages/jobs/jobs/src/pump.ts b/packages/jobs/jobs/src/pump.ts index 8f56b6abc6..3d7d252d24 100644 --- a/packages/jobs/jobs/src/pump.ts +++ b/packages/jobs/jobs/src/pump.ts @@ -17,7 +17,19 @@ export interface JobPumpRead { lossy: boolean } -/** One producer-owned stream the pump copies into a job record. */ +/** + * One producer-owned stream the pump copies into a job record. + * + * The source is a pull-by-offset snapshot reader, not a Node.js `Readable`, + * because the substrate it reads (the subprocess `readFrom` family) is a + * retained ring that several readers share: the model-facing consuming + * cursor, the foreground result collector, and this record pump each hold + * their own absolute offset and read without disturbing the others. A + * `Readable` is single-consumer and consuming, so wrapping the ring in one + * would need a tee per reader and could not express `lossy` — the reader's + * offset having slid out of the retained window — which the record turns + * into a visible `gapBefore` instead of a silent splice. + */ export interface JobPumpSource { /** Stream label attached to every chunk this source yields. */ channel?: JobChannel diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e03d24425c..181c30268c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -665,6 +665,46 @@ importers: specifier: ^4.4.3 version: 4.4.3 + packages/api/job-controller: + dependencies: + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../gateway + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:^ + version: link:../../typert/registry + packages/api/remotes: dependencies: '@deepseek-ai/dsh-deque': @@ -686,6 +726,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway + '@deepseek-ai/dsh-api-job-controller': + specifier: workspace:^ + version: link:../job-controller '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../session-controller @@ -1446,6 +1489,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-api-job-controller': + specifier: workspace:^ + version: link:../../api/job-controller '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -2507,6 +2553,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-job-controller': + specifier: workspace:^ + version: link:../../api/job-controller '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4dde46a13a..ac52455c6d 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -109,6 +109,7 @@ export const SERVICE_PAGE: Record = { subprocess: 'subprocess.md', systemPrompt: 'system-prompt.md', jobs: 'jobs.md', + jobController: 'jobs.md', sessionTelemetry: 'session-telemetry.md', agentTeams: 'agent-team.md', tokenMeter: 'token-meter.md', @@ -173,7 +174,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API', workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API', - jobOutput: 'client-side interface-typed browser service — packages/api/session-controller/README.md owns the API', + jobOutput: 'client-side interface-typed browser service — packages/api/job-controller/README.md owns the API', } /** @@ -324,8 +325,6 @@ export const LINK_MAP: Readonly> = { OptionalSessionSeq: 'session.md', SessionListRequest: 'session.md', SessionListValue: 'session.md', - SessionObserveJobFrame: 'session.md', - SessionObserveJobRequest: 'session.md', ModelCatalog: 'session.md', SessionOpenWorkspacePathRequest: 'session.md', SessionOpenWorkspacePathValue: 'session.md', @@ -508,7 +507,10 @@ export const LINK_MAP: Readonly> = { ToolProviderResult: 'system-prompt.md', JobDoneListener: 'jobs.md', JobId: 'jobs.md', + JobObserveFrame: 'jobs.md', + JobObserveRequest: 'jobs.md', JobRead: 'jobs.md', + JobWireChunk: 'jobs.md', JobSnapshot: 'jobs.md', JobStart: 'jobs.md', JobsChangedListener: 'jobs.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index f97eb086ee..43ccb69a51 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -170,6 +170,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Lists the Session composition\'s user-invocable skills without activating a cold Agent.', }, + { + key: 'jobController', + pkg: 'api-job-controller', + title: 'Host job Remote controller', + mode: 'core', + note: 'Streams one background job\'s observation record over the generated Remote namespace; the roster stays on the session control stream.', + }, { key: 'credentialsController', pkg: 'api-settings-controller', @@ -576,7 +583,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Background job registry', mode: 'seam', implementations: ['jobs-local'], - consumers: ['tool-bash', 'tool-pwsh', 'tool-terminal', 'tool-subagent', 'tool-jobs'], + consumers: ['tool-bash', 'tool-pwsh', 'tool-terminal', 'tool-subagent', 'tool-jobs', 'api-job-controller'], note: 'Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.', }, { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1cd954a9ca..9bb2cc7956 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -165,6 +165,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/test-support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, 'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, + 'packages/api/job-controller': { kind: 'none', reason: 'Job observation is browser and Host control state; it registers no prompt, tool, or session event.' }, 'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' }, 'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' }, 'packages/api/workspace-controller': { kind: 'none', reason: 'Workspace API and state projection owner; it registers no prompt, tool, or session event.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 0510427c3b..fc8d7e4e0f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -45,6 +45,9 @@ "@deepseek-ai/dsh-api-gateway": ["./packages/api/gateway/src/index.ts"], "@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"], "@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"], + "@deepseek-ai/dsh-api-job-controller": ["./packages/api/job-controller/src/index.ts"], + "@deepseek-ai/dsh-api-job-controller/client": ["./packages/api/job-controller/src/client/index.ts"], + "@deepseek-ai/dsh-api-job-controller/types": ["./packages/api/job-controller/src/types.ts"], "@deepseek-ai/dsh-api-session-controller": ["./packages/api/session-controller/src/index.ts"], "@deepseek-ai/dsh-api-session-controller/client": ["./packages/api/session-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-session-controller/types": ["./packages/api/session-controller/src/types.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index bb6735bbc7..cc2c7ac7fd 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -58,6 +58,7 @@ { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, { "path": "./packages/api/workspace-controller/tsconfig.client.json" }, + { "path": "./packages/api/job-controller/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/extensions/cordis-client-runner" }, { "path": "./packages/extensions/ui-cordis" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index c3c119a282..f8ae95c3d2 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -151,6 +151,7 @@ { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, + { "path": "./packages/api/job-controller/tsconfig.host.json" }, { "path": "./packages/api/session-controller/tsconfig.host.json" }, { "path": "./packages/api/settings-controller" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, From e42eb13655f6773906f21e3c229941faed533d4f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 22:58:24 +0800 Subject: [PATCH 022/141] test(web): drop the stray slash after the sourceRoot token in three prompt pins --- snapshots/web/cordis-tool-round/system-prompt.expected.md | 2 +- snapshots/web/fresh-round-trip/system-prompt.expected.md | 2 +- snapshots/web/ptc-round/system-prompt.expected.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/snapshots/web/cordis-tool-round/system-prompt.expected.md b/snapshots/web/cordis-tool-round/system-prompt.expected.md index 4b4a62c230..a7337e1dd2 100644 --- a/snapshots/web/cordis-tool-round/system-prompt.expected.md +++ b/snapshots/web/cordis-tool-round/system-prompt.expected.md @@ -1,6 +1,6 @@ You are an AI agent powered by DeepSeek Harness. -The DeepSeek Harness implementation checkout is at {{sourceRoot}}/. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/snapshots/web/fresh-round-trip/system-prompt.expected.md b/snapshots/web/fresh-round-trip/system-prompt.expected.md index b20c5e5f77..fda55c1dea 100644 --- a/snapshots/web/fresh-round-trip/system-prompt.expected.md +++ b/snapshots/web/fresh-round-trip/system-prompt.expected.md @@ -1,6 +1,6 @@ You are an AI agent powered by DeepSeek Harness. -The DeepSeek Harness implementation checkout is at {{sourceRoot}}/. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index ab15ca15ac..fc9cac9176 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -1,6 +1,6 @@ You are an AI agent powered by DeepSeek Harness. -The DeepSeek Harness implementation checkout is at {{sourceRoot}}/. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. From 761282724b6ae6dbea39f9ad2dd4e5293ba67285 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 11:23:41 +0800 Subject: [PATCH 023/141] fix(jobs): constant pump waits, quiescent client disposal, record flag on the roster Review corrections on the observation record: - `pumpJobOutput` subscribes to `done` once and keeps one pending timer; racing the same pending promise every poll round retained a reaction per round for the life of the job. - The `ctx.jobOutput` effect disposer is async and awaits every open carrier's `dispose()`, so the fiber reports unloaded only once the old iterators are quiescent. - `SessionJob.record: true` replaces `outputTotal`: `append` never commits a roster change, so the mirrored count was stale while output flowed; the live offsets ride the observation stream's `opened` anchor. - The record is documented as a best-effort live preview whose cross-stream reordering is bounded by the poll interval. - The web e2e scenarios hold their background job on a test-owned barrier file instead of a wall-clock sleep, and each is one test rather than a chain sharing a job id. --- ...9-01-jobs-absorb-activity-record.i18n.yaml | 4 +-- .../2026-09-01-jobs-absorb-activity-record.md | 14 ++++++-- ...26-09-01-jobs-absorb-activity-record.zh.md | 14 ++++++-- apps/web/tests/background-job-list.e2e.ts | 34 +++++++++--------- apps/web/tests/live-job-stream.e2e.ts | 34 +++++++++--------- .../live-job-stream/settled.expected.md | 6 ++-- .../live-job-stream/streaming.expected.md | 6 ++-- docs/event-producer-consumer.i18n.yaml | 4 +-- docs/event-producer-consumer.md | 10 +++--- docs/event-producer-consumer.zh.md | 10 +++--- packages/api/job-controller/README.i18n.yaml | 4 +-- packages/api/job-controller/README.md | 3 +- packages/api/job-controller/README.zh.md | 3 +- .../api/job-controller/src/client/service.ts | 12 ++++--- .../job-controller/tests/model.client.spec.ts | 24 +++++++++++++ .../api/session-controller/README.i18n.yaml | 4 +-- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../api/session-controller/src/control.ts | 2 +- packages/api/session-controller/src/types.ts | 9 ++--- .../tests/control-jobs.host.spec.ts | 5 +-- packages/client/ui-jobs/README.i18n.yaml | 4 +-- packages/client/ui-jobs/README.md | 2 +- packages/client/ui-jobs/README.zh.md | 2 +- .../ui-jobs/src/client/JobListAction.tsx | 2 +- .../tests/job-list-action.client.spec.tsx | 4 +-- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/jobs/jobs/src/pump.ts | 34 ++++++++++++------ packages/jobs/jobs/tests/service.spec.ts | 36 +++++++++++++++++++ packages/shell/tool-bash/README.i18n.yaml | 4 +-- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/src/observe.ts | 3 ++ packages/shell/tool-pwsh/src/observe.ts | 3 ++ .../background-job-list/running.expected.md | 4 +-- .../background-job-list/settled.expected.md | 4 +-- 36 files changed, 212 insertions(+), 102 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml index 3a332e7121..f7cf077333 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md -2026-09-01-jobs-absorb-activity-record.md: aa147778d06133b61f79defae6f9f6807dd97f41 -2026-09-01-jobs-absorb-activity-record.zh.md: 12e8f4bbd909a480f1461433c58d7937a8d7c857 +2026-09-01-jobs-absorb-activity-record.md: c1a668f8a008948957e62b5acf1d9db141441885 +2026-09-01-jobs-absorb-activity-record.zh.md: a23f3cda4c8c53663a6aee99469297d6878539d1 diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md index aa147778d0..c1a668f8a0 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -18,8 +18,8 @@ The one activity without a job was the foreground workflow mirror. Everything el - **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented. - **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design. - **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it. -- **The wire splits by role.** `SessionJob.outputTotal` (present exactly for record jobs) marks a row observable on the session control stream; `job.observe({sessionId?, jobId, from?})` on the `job` namespace of `api-job-controller` streams anchor/output/status frames with the fenced read resolved from the request's session. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery lives in `api-job-controller` (`observe.ts`, client `ctx.jobOutput`), which resolves its Remote faces in its own apply. -- **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `outputTotal` is present. The two-roster join is deleted. +- **The wire splits by role.** `SessionJob.record` (present exactly for record jobs) marks a row observable on the session control stream; `job.observe({sessionId?, jobId, from?})` on the `job` namespace of `api-job-controller` streams anchor/output/status frames with the fenced read resolved from the request's session. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery lives in `api-job-controller` (`observe.ts`, client `ctx.jobOutput`), which resolves its Remote faces in its own apply. +- **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `record` is present. The two-roster join is deleted. - **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows. ## Alternatives considered @@ -30,6 +30,16 @@ The one activity without a job was the foreground workflow mirror. Everything el The [prior seam note](../feature/2026-08-24-activity-observation-seam.md)'s argument against durable session events and control-stream framing for live output still holds and carries over unchanged: the record is process-local observation state, never a session event, so "model-visible ⟺ logged" is untouched. +## Review corrections + +Review of the merged design settled five points that the sections above leave implicit: + +- **The pump's wait holds constant resources.** `pumpJobOutput` subscribes to the producer's `done` once and keeps one pending timer; settlement clears the timer and wakes the current wait. Racing the same pending promise every poll round retained a reaction per round until the job ended — a day-long job at the default cadence accumulated over a million closures. +- **Client disposal waits for carrier quiescence.** The `ctx.jobOutput` effect disposer is async and awaits every open `RemoteStream.dispose()`, because Cordis awaits async disposers and a fiber that reported unloaded while an old iterator was still closing could overlap the next plugin instance under HMR. +- **The roster flags a record, it does not count bytes.** `SessionJob.record: true` replaced `outputTotal`: `append` commits no roster change (only lifecycle commits fire `onJobsChanged`), so a mirrored count went stale the moment output arrived, and broadcasting the roster per append was rejected as traffic for a number no row needed. The live offsets ride the observation stream's `opened` anchor. +- **The record is a best-effort live preview.** Producers copy stdout and stderr per poll round, so writes inside one window land stdout first; pipe capture cannot recover the true interleaving (only a PTY or `2>&1` can), and the client concatenates chunks regardless of `channel`. `channel` stays on the wire because it is what makes that limit visible and what a distinct stderr rendering would need. +- **Web e2e keep-alive is a barrier, not a clock.** The background command holds on a file the test owns in the job's cwd and the scenario kills it explicitly, so no CI stall can settle the job first; each scenario is one test, never a chain of tests sharing a job id. + ## Consequences Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `api-job-controller`. diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md index 12e8f4bbd9..a23f3cda4c 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -18,8 +18,8 @@ Status: implemented - **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。 - **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。 - **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。 -- **wire 按角色拆分。** `SessionJob.outputTotal`(恰在 record job 上存在)在会话控制流上标记行可观测;`api-job-controller` 的 `job` 命名空间上的 `job.observe({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制位于 `api-job-controller`(`observe.ts`,客户端 `ctx.jobOutput`),它在自己的 apply 里解析所依赖的 Remote 面。 -- **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `outputTotal` 存在时可展开。双名册 join 被删除。 +- **wire 按角色拆分。** `SessionJob.record`(恰在 record job 上存在)在会话控制流上标记行可观测;`api-job-controller` 的 `job` 命名空间上的 `job.observe({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制位于 `api-job-controller`(`observe.ts`,客户端 `ctx.jobOutput`),它在自己的 apply 里解析所依赖的 Remote 面。 +- **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `record` 存在时可展开。双名册 join 被删除。 - **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。 ## 曾考虑的替代方案 @@ -30,6 +30,16 @@ Status: implemented [先前 seam note](../feature/2026-08-24-activity-observation-seam.zh.md) 反对用持久会话事件或 control 流承载实时输出的论证仍然成立并原样沿用:record 是进程本地观测状态,从不是会话事件,「模型可见 ⟺ 已记录」不受影响。 +## 评审修正 + +对已合并设计的评审确定了上文各节未言明的五点: + +- **泵的等待资源恒定。** `pumpJobOutput` 对生产者的 `done` 只订阅一次,同一时刻只保留一个待触发的 timer;结算清掉 timer 并唤醒当前等待。此前每个轮询轮次都对同一个 pending promise 赛跑,每轮留下一个 reaction 直到任务结束——默认节奏下跑一天的任务会累积上百万个闭包。 +- **客户端卸载等待载体静止。** `ctx.jobOutput` 的 effect disposer 是异步的,await 每条打开的 `RemoteStream.dispose()`:Cordis 会 await 异步 disposer,而一个在旧 iterator 仍在关闭时就宣告卸载完成的 fiber,在 HMR 下可能与下一个插件实例重叠。 +- **名册标记 record,不计字节。** `SessionJob.record: true` 取代了 `outputTotal`:`append` 不提交名册变更(只有生命周期提交触发 `onJobsChanged`),镜像的计数在输出到达的瞬间就陈旧了;而为一个没有任何行需要的数字按 append 广播整份名册被否决。实时偏移随观测流的 `opened` 锚帧到达。 +- **record 是尽力而为的实时预览。** 生产者按轮询轮次复制 stdout 与 stderr,同一窗口内的写入先 stdout 落地;pipe 捕获无法还原真实交错(只有 PTY 或 `2>&1` 可以),客户端拼接 chunk 时也不区分 `channel`。`channel` 留在 wire 上,因为它正是让这一限制可见、也是将来区分渲染 stderr 所需要的东西。 +- **Web e2e 的保活是 barrier 而非时钟。** 后台命令守在测试拥有的、位于 job cwd 的文件上,由场景显式 kill,因此 CI 卡顿不可能先把任务结算掉;每个场景是一个测试,绝不是共享 job id 的测试链。 + ## 后果 每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `api-job-controller`。 diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 284b359eeb..96a7f4f833 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -1,6 +1,6 @@ // Session-header background jobs driven by a real `ctx.jobs` entry. No model // call is involved. -import { readFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -22,9 +22,12 @@ const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'background-job-list-web-e2e' -// Long enough that the running assertions never race the process exiting on -// their own; the test kills it explicitly to reach the settled state. -const COMMAND = 'sleep 45' +// A hold on a barrier file the test owns in the job's cwd: the process never +// exits on its own, so no CI stall can settle it before the scenario kills it. +// The bounded loop only caps an orphan's lifetime if the runner dies before +// `afterAll` releases the barrier. +const RELEASE = '.background-job-list.release' +const COMMAND = `for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done` /** * Wait for opening a session to publish its live Agent. @@ -48,7 +51,6 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { let page: Page let tripwire: ReturnType let agent: Agent - let jobId: JobId beforeAll(async () => { scaffold = await launchWebScaffold({}) @@ -73,12 +75,14 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { }, 120_000) afterAll(async () => { + if (scaffold !== undefined) await writeFile(join(scaffold.workspaceCwd, RELEASE), '') await browser?.close() await scaffold?.close() }) - it('shows a running background job in the session header without a refresh', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running')) + it('shows a running background job in the header without a refresh, then flips it to cancelled on settlement', async () => { + let phase = 'running' + onTestFailed(() => saveFailureShot(page, `web-e2e-background-job-${phase}`)) // Polling for zero would pass at t=0 before delivery and prove nothing. const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) @@ -93,7 +97,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') const matched = /\bbash-\d+\b/.exec(reported) if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) - jobId = JobId(matched[0]) + const jobId = JobId(matched[0]) await trigger.waitFor({ timeout: 15_000 }) await trigger.click() @@ -101,24 +105,22 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { await row.waitFor({ timeout: 10_000 }) await expect.poll(() => row.textContent()).toContain(COMMAND) - const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE) + const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - }, 60_000) - it('flips the open list to the cancelled outcome when the registry settles it', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled')) + phase = 'settled' expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') const idle = page.getByRole('button', { name: '1 background job', exact: true }) await idle.waitFor({ timeout: 20_000 }) - const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - }, 60_000) + }, 90_000) it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md']) diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts index 65926f7463..83736763c6 100644 --- a/apps/web/tests/live-job-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -2,7 +2,7 @@ // arrives over the session control stream, expanding it opens the record // observation stream, and the panel shows the process's real output while it // is still running. No model call is involved. -import { readFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -24,9 +24,12 @@ const STREAMING_EXPECTED = join(SNAPSHOT_DIR, 'streaming.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'live-job-stream-web-e2e' -// One fixed line of early output, then silence long enough that the streaming -// assertions never race the process exiting; the test kills it explicitly. -const COMMAND = "printf 'streamed-%s\\n' marker-line; sleep 45" +// One fixed line of early output, then a hold on a barrier file the test owns +// in the job's cwd: the process never exits on its own, so no CI stall can +// settle it before the scenario kills it. The bounded loop only caps an +// orphan's lifetime if the runner dies before `afterAll` releases the barrier. +const RELEASE = '.live-job-stream.release' +const COMMAND = `printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done` /** * Wait for opening a session to publish its live Agent. @@ -50,7 +53,6 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { let page: Page let tripwire: ReturnType let agent: Agent - let jobId: JobId beforeAll(async () => { scaffold = await launchWebScaffold({}) @@ -74,12 +76,14 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { }, 120_000) afterAll(async () => { + if (scaffold !== undefined) await writeFile(join(scaffold.workspaceCwd, RELEASE), '') await browser?.close() await scaffold?.close() }) - it('streams a running background command\'s real output into the expanded panel', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-live-job-streaming')) + it('streams a running job\'s real output into the expanded panel, then settles it in place on kill', async () => { + let phase = 'streaming' + onTestFailed(() => saveFailureShot(page, `web-e2e-live-job-${phase}`)) const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) @@ -93,7 +97,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') const matched = /\bbash-\d+\b/.exec(reported) if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) - jobId = JobId(matched[0]) + const jobId = JobId(matched[0]) // The job row reaches the header over the session control stream. await trigger.waitFor({ timeout: 15_000 }) @@ -106,14 +110,12 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { await expand.click() await page.getByText('streamed-marker-line').waitFor({ timeout: 15_000 }) - const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(STREAMING_EXPECTED, snapshot, MODE) + const streaming = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STREAMING_EXPECTED, streaming, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - }, 60_000) - it('settles the open panel when the job is killed, keeping the streamed tail', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-live-job-settled')) + phase = 'settled' expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') // Settlement arrives on the observation stream itself, so the open panel @@ -122,11 +124,11 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { await idle.waitFor({ timeout: 20_000 }) await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) - const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - }, 60_000) + }, 90_000) it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['settled.expected.md', 'streaming.expected.md']) diff --git a/apps/web/tests/snapshots/live-job-stream/settled.expected.md b/apps/web/tests/snapshots/live-job-stream/settled.expected.md index 6ba8ada46d..4c3fdfc70f 100644 --- a/apps/web/tests/snapshots/live-job-stream/settled.expected.md +++ b/apps/web/tests/snapshots/live-job-stream/settled.expected.md @@ -1,8 +1,8 @@ - list "Background jobs": - listitem: - - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM {{duration}}" + - button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: + - text: "bash printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done signal: SIGTERM {{duration}}" - img - - text: $ printf 'streamed-%s\n' marker-line; sleep 45 + - text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done - button "Copy" - text: streamed-marker-line diff --git a/apps/web/tests/snapshots/live-job-stream/streaming.expected.md b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md index 4054d5d42a..6b7336b859 100644 --- a/apps/web/tests/snapshots/live-job-stream/streaming.expected.md +++ b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md @@ -1,8 +1,8 @@ - list "Background jobs": - listitem: - - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: - - text: printf 'streamed-%s\n' marker-line; sleep 45 {{duration}} bash running + - button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: + - text: printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done {{duration}} bash running - img - - text: $ printf 'streamed-%s\n' marker-line; sleep 45 + - text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done - button "Copy" - text: streamed-marker-line diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 8a378b23f1..9d71df140c 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 930ffba24789ee51db110310cb17a2231135cf1d -event-producer-consumer.zh.md: 6583b1d9f333ad934db2123a2bc5c40b12f1ae36 +event-producer-consumer.md: e0b4709875723bd96f9910b1bf518600612f0a12 +event-producer-consumer.zh.md: 0f0fa1317eb5a017daf435648f5c3e36208626e0 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 930ffba247..e0b4709875 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:550`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:557`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:551`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 6583b1d9f3..0f0fa1317e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:550`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:557`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:551`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/packages/api/job-controller/README.i18n.yaml b/packages/api/job-controller/README.i18n.yaml index 9bef50c478..49a565d20f 100644 --- a/packages/api/job-controller/README.i18n.yaml +++ b/packages/api/job-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/job-controller/README.md -README.md: 1af9cac6574b36fb9ee0fe2bd4ff4a529848c2fe -README.zh.md: a232d8e8655fea6ccddedb60e222837466705549 +README.md: 6aaab1bff1f13cc21672a039170f811b570230db +README.zh.md: 47e68802346fbc5e41e74171a42afa17289ee156 diff --git a/packages/api/job-controller/README.md b/packages/api/job-controller/README.md index 1af9cac657..6aaab1bff1 100644 --- a/packages/api/job-controller/README.md +++ b/packages/api/job-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its one Remote stream, `job.observe`, delivers a record-declaring job's retained output from an absolute byte offset; the Client half installs `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views the session-header job list renders. The roster itself is not here: `SessionJob` rows still ride the session control stream owned by [`dsh-api-session-controller`](../session-controller/README.md), and a row's `outputTotal` says whether this controller has anything to stream for it. +`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its one Remote stream, `job.observe`, delivers a record-declaring job's retained output from an absolute byte offset; the Client half installs `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views the session-header job list renders. The roster itself is not here: `SessionJob` rows still ride the session control stream owned by [`dsh-api-session-controller`](../session-controller/README.md), and a row's `record` says whether this controller has anything to stream for it. ## Table of Contents @@ -50,6 +50,7 @@ No direct effect; observation reads never touch model requests. +- The record is a best-effort live preview, not a terminal transcript: producers copy stdout and stderr per poll round, so the two streams' writes inside one poll window reach the record stdout first, and the client concatenates chunks regardless of `channel`. - The stream is process-local: a Host restart loses every record, and a resumed observation then anchors on an empty registry. - Per-session fencing is enforced by the registry read; the Remote layer itself serves any connected browser, matching the session control stream's `jobsBySession` broadcast. diff --git a/packages/api/job-controller/README.zh.md b/packages/api/job-controller/README.zh.md index a232d8e865..47e6880234 100644 --- a/packages/api/job-controller/README.zh.md +++ b/packages/api/job-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它唯一的 Remote 流 `job.observe` 从绝对字节偏移发送声明了 record 的 job 的保留输出;Client 半侧安装 `ctx.jobOutput`——按 job 引用计数的观测服务,会话头部任务列表渲染其累积视图。名册不在这里:`SessionJob` 行仍随 [`dsh-api-session-controller`](../session-controller/README.zh.md) 拥有的会话控制流到达,行上的 `outputTotal` 说明本控制器是否有东西可流。 +`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它唯一的 Remote 流 `job.observe` 从绝对字节偏移发送声明了 record 的 job 的保留输出;Client 半侧安装 `ctx.jobOutput`——按 job 引用计数的观测服务,会话头部任务列表渲染其累积视图。名册不在这里:`SessionJob` 行仍随 [`dsh-api-session-controller`](../session-controller/README.zh.md) 拥有的会话控制流到达,行上的 `record` 说明本控制器是否有东西可流。 ## 目录 @@ -50,6 +50,7 @@ Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jo +- record 是尽力而为的实时预览,不是终端转录:生产者按轮询轮次复制 stdout 与 stderr,同一轮询窗口内两条流的写入先 stdout 后到达 record,客户端拼接 chunk 时也不区分 `channel`。 - 流是进程本地的:Host 重启丢失全部 record,续读的观测随后锚定在空注册表上。 - 按 session 的围栏由注册表读取强制;Remote 层本身服务任何已连接浏览器,与会话控制流对 `jobsBySession` 的广播一致。 diff --git a/packages/api/job-controller/src/client/service.ts b/packages/api/job-controller/src/client/service.ts index 385a0b92ad..07f3a00ec2 100644 --- a/packages/api/job-controller/src/client/service.ts +++ b/packages/api/job-controller/src/client/service.ts @@ -74,13 +74,15 @@ export class ClientJobOutput extends Service implements IJobOutput { ) { super(ctx, 'jobOutput') this.state = model - ctx.effect(() => () => { + ctx.effect(() => async () => { const open = [...this.entries.values()] this.entries.clear() - for (const entry of open) { - entry.stopped = true - void entry.dispose() - } + for (const entry of open) entry.stopped = true + // Cordis awaits an async disposer, so the fiber stays unloading until + // every carrier iterator has closed and a successor plugin instance + // cannot overlap one. A carrier whose teardown fails is stopped all the + // same; its failure has no consumer here. + await Promise.allSettled(open.map(entry => entry.dispose())) }, 'job-controller.client.observations') } diff --git a/packages/api/job-controller/tests/model.client.spec.ts b/packages/api/job-controller/tests/model.client.spec.ts index 69260ff3fc..5e49771318 100644 --- a/packages/api/job-controller/tests/model.client.spec.ts +++ b/packages/api/job-controller/tests/model.client.spec.ts @@ -391,6 +391,30 @@ describe('ClientJobOutput observation streams', () => { expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined() }) + it('holds service disposal until every open stream reports quiescence', async () => { + const { ctx, output, streams } = bench() + output.observe(undefined, ID) + output.observe(undefined, 'bash-2' as JobId) + for (const { stream } of streams) stream.deferDispose = true + await tick() + + let settled = false + const disposal = ctx.fiber.dispose().then(() => { settled = true }) + await tick() + // Both carriers were told to stop, but neither iterator has closed yet: + // the fiber must still be unloading, or a successor plugin instance could + // overlap the old generation's iterator. + expect(streams.every(entry => entry.stream.disposed)).toBe(true) + expect(settled).toBe(false) + + streams[0]!.stream.releaseDispose() + await tick() + expect(settled).toBe(false) + streams[1]!.stream.releaseDispose() + await disposal + expect(settled).toBe(true) + }) + it('records a terminal stream failure and service disposal closes live streams', async () => { const { ctx, model, output, streams } = bench() output.observe(undefined, ID) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index fa85bc3ebe..9f55558821 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 4f94607e70a5b963fa50d7a497afbcc97d273cb9 -README.zh.md: 455769f77ce8ad304949624de938e56579024145 +README.md: 4414cd7221b40cc880122415e83b6dc3818df481 +README.zh.md: 8f457d4a94f57c802f67624d9c84ba15c78b1ada diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 4f94607e70..4414cd7221 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. -A `SessionJob` row carries `outputTotal` exactly when the job declared an observation record; streaming that record is the job controller's `job.observe` (see [`dsh-api-job-controller`](../job-controller/README.md)), while this package keeps the roster on the control stream. +A `SessionJob` row carries `record: true` exactly when the job declared an observation record; streaming that record is the job controller's `job.observe` (see [`dsh-api-job-controller`](../job-controller/README.md)), while this package keeps the roster on the control stream. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 455769f77c..8f457d4a94 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 -`SessionJob` 行恰在该 job 声明了观测 record 时携带 `outputTotal`;流式发送该 record 的是 job 控制器的 `job.observe`(见 [`dsh-api-job-controller`](../job-controller/README.zh.md)),本包只在控制流上保留名册。 +`SessionJob` 行恰在该 job 声明了观测 record 时携带 `record: true`;流式发送该 record 的是 job 控制器的 `job.observe`(见 [`dsh-api-job-controller`](../job-controller/README.zh.md)),本包只在控制流上保留名册。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index aa9f0a77be..b96cbb7f9d 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -212,6 +212,6 @@ function jobView(job: JobSnapshot): SessionJob { ...(job.detail === undefined ? {} : { detail: job.detail }), startedAt: job.startedAt, ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }), - ...(job.outputTotal === undefined ? {} : { outputTotal: job.outputTotal }), + ...(job.outputTotal === undefined ? {} : { record: true as const }), } } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c5a6caeacb..d3ab795ecb 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -489,11 +489,12 @@ export interface SessionJob { readonly startedAt: number readonly finishedAt?: number /** - * Total UTF-8 bytes the job's observation record holds so far. Present - * exactly when the job declared a record — the row is observable through - * the job controller's `job.observe` stream. + * Present exactly when the job declared an observation record: the row is + * observable through the job controller's `job.observe` stream, whose + * `opened` anchor carries the live byte offsets. The roster refreshes on + * lifecycle commits only, so it mirrors no byte count. */ - readonly outputTotal?: number + readonly record?: true } /** Complete live control baseline emitted once per control stream generation. */ diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts index 61a9a8156a..9d80501966 100644 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -162,7 +162,7 @@ describe('Session control jobs updates', () => { ]) }) - it('carries outputTotal exactly for record-declaring jobs', async () => { + it('flags record-declaring jobs instead of mirroring a byte count the roster never refreshes', async () => { const { ctx, agent, control } = await harness(true) const abort = new AbortController() const collected = collectJobs(control.control(abort.signal), 1, abort) @@ -178,7 +178,8 @@ describe('Session control jobs updates', () => { }) const [frame] = await collected - expect(frame?.jobs[0]?.outputTotal).toBe(2) + expect(frame?.jobs[0]).toMatchObject({ record: true }) + expect(frame?.jobs[0]).not.toHaveProperty('outputTotal') }) it('fans an unowned change out to every attached session', async () => { diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index fe508b8379..7a5e54a34f 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-jobs/README.md -README.md: a0ecc137ab1eb57aeaea9ccdfdbaf898794de2fe -README.zh.md: 744450c480b4d26c0558d992904817174ca2ed73 +README.md: fe357b5555cdc2f7a94ea0a44e97c499652695d3 +README.zh.md: d23b96becc5b2598197f7720a355a37e5c8bf7cb diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index a0ecc137ab..fe357b5555 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -29,7 +29,7 @@ Load the plugin through the web-app manifest; it renders nothing until the sessi ### One row per job -The session control stream's `jobsBySession` mirror is the single roster: each `SessionJob` row carries lifecycle, duration, and the model-visible `detail`, and its `outputTotal` field is present exactly when the job declared a record — that presence is what makes a row expandable. There is no second roster to join. +The session control stream's `jobsBySession` mirror is the single roster: each `SessionJob` row carries lifecycle, duration, and the model-visible `detail`, and its `record` flag is present exactly when the job declared a record — that flag is what makes a row expandable. There is no second roster to join. ### The expanded panel diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index 744450c480..d23b96becc 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 一 job 一行 -session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJob` 行携带生命周期、时长与模型可见的 `detail`,其 `outputTotal` 字段恰在 job 声明了 record 时存在——这个存在性就是行可展开的依据。不存在需要 join 的第二份名册。 +session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJob` 行携带生命周期、时长与模型可见的 `detail`,其 `record` 标志恰在 job 声明了 record 时存在——这个标志就是行可展开的依据。不存在需要 join 的第二份名册。 ### 展开的面板 diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index 87acb18a32..bf369c9949 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -46,7 +46,7 @@ function isLive(job: SessionJob): boolean { /** True exactly when the job declared an observation record. */ function isObservable(job: SessionJob): boolean { - return job.outputTotal !== undefined + return job.record === true } /** Closed-union exhaustiveness fence for the wire status set. */ diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index 9d659ca2ae..9a8b9a1edc 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -30,7 +30,7 @@ function job(over: Partial = {}): SessionJob { /** A job that declared an observation record, so its row offers a panel. */ function recordJob(over: Partial = {}): SessionJob { - return job({ outputTotal: 0, ...over }) + return job({ record: true, ...over }) } function props( @@ -180,7 +180,7 @@ describe('JobListAction observation', () => { streaming: false, } render() diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9de2adb4c9..3002c82a56 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4987,7 +4987,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionJob', - declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal?: number;\n}', + declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly record?: true;\n}', }, { name: 'SessionLineageNode', diff --git a/packages/jobs/jobs/src/pump.ts b/packages/jobs/jobs/src/pump.ts index 3d7d252d24..5203157a9f 100644 --- a/packages/jobs/jobs/src/pump.ts +++ b/packages/jobs/jobs/src/pump.ts @@ -60,6 +60,14 @@ export interface JobPumpOptions { * `gapBefore`, so the discontinuity stays visible to observers. Fold the * returned promise into the producer's `JobHooks.done` chain so the final * drain lands before settlement trims and closes the record. + * + * Each round drains the sources in array order, so bytes two sources + * produced inside one poll window land in that order, not in the order they + * were written; the record is a best-effort live view whose cross-source + * reordering is bounded by `pollMs`, not a terminal transcript. + * + * The wait holds constant resources however long the job runs: one + * subscription on `done` for the whole run and one pending timer at a time. * @param job - the running job's producer face receiving the copied chunks. * @param sources - producer streams, each pumped at its own offset. * @param options - poll cadence and the producer's settlement promise. @@ -91,18 +99,24 @@ export async function pumpJobOutput( } } let settled = false - const settlement = options.done.then(() => { settled = true }, () => { settled = true }) + let timer: ReturnType | undefined + let wake: (() => void) | undefined + const finish = (): void => { + settled = true + // Clear the pending poll so a finished pump holds no timer for up to one + // interval, and release the current wait so the final drain runs at once. + if (timer !== undefined) clearTimeout(timer) + wake?.() + } + void options.done.then(finish, finish) while (!settled) { drain() - await Promise.race([ - new Promise((resolve) => { - const timer = setTimeout(resolve, options.pollMs) - // The settlement branch wins the race; clear the loser so a finished - // pump holds no pending timer for up to one poll interval. - void settlement.then(() => { clearTimeout(timer); resolve() }) - }), - settlement, - ]) + await new Promise((resolve) => { + wake = resolve + timer = setTimeout(resolve, options.pollMs) + }) + wake = undefined + timer = undefined } drain() } diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index ff2c282a27..3938328478 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -173,6 +173,42 @@ describe('pumpJobOutput', () => { } }) + it('subscribes to done once and holds one poll timer however long the job runs', async () => { + vi.useFakeTimers() + try { + const { job } = recordingJob() + // A thenable stands in for the producer's done promise, and whatever the + // pump derives from it counts its own reactions too: a pending promise + // retains every reaction until it settles, so a day-long job must not + // gain one per poll round. + let subscriptions = 0 + let settle!: () => void + const settled = new Promise((resolve) => { settle = resolve }) + const counting = (promise: Promise): Promise => ({ + then(onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) { + subscriptions += 1 + return counting(promise.then(onFulfilled, onRejected)) + }, + }) as unknown as Promise + const done = counting(settled) + const { source, offsets } = scriptedSource([]) + const pump = pumpJobOutput(job, [source], { pollMs: 50, done }) + + const rounds = 10_000 + await vi.advanceTimersByTimeAsync(50 * rounds) + expect(offsets).toHaveLength(rounds + 1) + expect(subscriptions).toBeLessThanOrEqual(2) + expect(vi.getTimerCount()).toBe(1) + + settle() + await pump + expect(offsets).toHaveLength(rounds + 2) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('marks a lossy source read as a gap so observers see the discontinuity', async () => { const { job, appends } = recordingJob() const { source } = scriptedSource([{ text: 'tail', lossy: true }]) diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index 194557fca7..57f2951bfa 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-bash/README.md -README.md: 24e13266a7ee24b39f524f165401076860f494b1 -README.zh.md: 3d2c9f22033d711ce33382054a372e2c26aa2d46 +README.md: 50047a5fe0d69da1feb38f75ada27595b88fa5c5 +README.zh.md: c74a45561298defb4baa5b34be7cebc1cdfbc744 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 24e13266a7..50047a5fe0 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -55,7 +55,7 @@ The tool executes `bash -c ` and returns the combined output. Commands ### Running long commands in the background -Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. The background job declares an observation record: the run's non-consuming `observed` readers are pumped into it at `recordPollMs` (default 150), so the Web client streams live output without touching the job's consuming cursor; a pump failure is logged and swallowed while the job runs on. +Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. The background job declares an observation record: the run's non-consuming `observed` readers are pumped into it at `recordPollMs` (default 150), so the Web client streams live output without touching the job's consuming cursor; a pump failure is logged and swallowed while the job runs on. The record is a best-effort live preview: stdout and stderr are copied per poll round, so writes the two streams made inside one `recordPollMs` window appear stdout first rather than in write order. ### Sandboxed execution and escalation diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index 3d2c9f2203..c74a455612 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" ### 后台运行长时间命令 -传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。后台 job 声明观测 record:运行的非消费 `observed` 读取器以 `recordPollMs`(默认 150)的节奏泵入其中,使 Web 客户端在不触碰 job 消耗游标的情况下流式看到实时输出;泵失败会被记录并吞掉,job 继续运行。 +传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。后台 job 声明观测 record:运行的非消费 `observed` 读取器以 `recordPollMs`(默认 150)的节奏泵入其中,使 Web 客户端在不触碰 job 消耗游标的情况下流式看到实时输出;泵失败会被记录并吞掉,job 继续运行。record 是尽力而为的实时预览:stdout 与 stderr 按轮询轮次复制,同一个 `recordPollMs` 窗口内两条流的写入会先 stdout 后 stderr 出现,而不是按写入顺序。 ### 沙箱执行与升权 diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts index 2e2874f9c1..0758be7b7b 100644 --- a/packages/shell/tool-bash/src/observe.ts +++ b/packages/shell/tool-bash/src/observe.ts @@ -5,6 +5,9 @@ * `readOutput` cursor. Observation is strictly best-effort: absent backend * offset readers degrade to no observation, and a pump failure is logged and * swallowed — the job path is unaffected either way. + * The record interleaves stdout and stderr per poll round, not per write: + * output the two streams produced inside one `pollMs` window lands stdout + * first, so the panel is a live preview, not a terminal-exact transcript. * * @module @deepseek-ai/dsh-tool-bash/observe */ diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts index 6e9e466981..911145bd93 100644 --- a/packages/shell/tool-pwsh/src/observe.ts +++ b/packages/shell/tool-pwsh/src/observe.ts @@ -5,6 +5,9 @@ * `readOutput` cursor. Observation is strictly best-effort: absent backend * offset readers degrade to no observation, and a pump failure is logged and * swallowed — the job path is unaffected either way. + * The record interleaves stdout and stderr per poll round, not per write: + * output the two streams produced inside one `pollMs` window lands stdout + * first, so the panel is a live preview, not a terminal-exact transcript. * * @module @deepseek-ai/dsh-tool-pwsh/observe */ diff --git a/snapshots/web/background-job-list/running.expected.md b/snapshots/web/background-job-list/running.expected.md index 811bddec3c..b3772a0cdf 100644 --- a/snapshots/web/background-job-list/running.expected.md +++ b/snapshots/web/background-job-list/running.expected.md @@ -1,5 +1,5 @@ - list "Background jobs": - listitem: - - button "Show live output of sleep 45": - - text: sleep 45 {{duration}} bash running + - button "Show live output of for _ in $(seq 1 3000); do [ -e .background-job-list.release ] && break; sleep 0.2; done": + - text: for _ in $(seq 1 3000); do [ -e .background-job-list.release ] && break; sleep 0.2; done {{duration}} bash running - img diff --git a/snapshots/web/background-job-list/settled.expected.md b/snapshots/web/background-job-list/settled.expected.md index 44747d990d..8d4846be9c 100644 --- a/snapshots/web/background-job-list/settled.expected.md +++ b/snapshots/web/background-job-list/settled.expected.md @@ -1,5 +1,5 @@ - list "Background jobs": - listitem: - - button "Show live output of sleep 45": - - text: "bash sleep 45 signal: SIGTERM {{duration}}" + - button "Show live output of for _ in $(seq 1 3000); do [ -e .background-job-list.release ] && break; sleep 0.2; done": + - text: "bash for _ in $(seq 1 3000); do [ -e .background-job-list.release ] && break; sleep 0.2; done signal: SIGTERM {{duration}}" - img From 6a4d7ee2429aee6a31a8d5b281526b40c339fba9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 11:33:05 +0800 Subject: [PATCH 024/141] chore(job-controller): align the package version with the 0.1.2-rc.1 bump --- packages/api/job-controller/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json index 0f0e46aafe..71cec52468 100644 --- a/packages/api/job-controller/package.json +++ b/packages/api/job-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-job-controller", "description": "Job Remote observation stream and the reference-counted client job-output service", - "version": "0.1.2-alpha.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, From b48c05d117fe06296cc0f21c5fc77ba7abbecf4e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 13:15:23 +0800 Subject: [PATCH 025/141] refactor(jobs): type the producer face by the record declaration, tidy review residue - `JobStart` is a union discriminated by `record`: `RecordingJobStart.run` receives `RecordingJob` (with `append`), `PlainJobStart.run` receives the plain `RunningJob`, and the registry builds the plain face without the method, so the former warn-and-drop path is unrepresentable. - Remove the duplicated JSDoc opener in the registry seam and correct the job-list slot comment (order 20 sits before the subagent catalog's 30). - Restore the `cordis-tool-round` session fixture whose only change was a re-recorded `createdAt`. - Update the 2026-08-08 job-display note's stale service-disposal and slot ordering facts; archive the superseded 2026-08-25 unified-task-list note and point its remaining inbound links at the archived snapshot. --- .../2026-08-25-unified-task-list.i18n.yaml | 6 ++ .../feature/2026-08-25-unified-task-list.md | 5 +- .../2026-08-25-unified-task-list.zh.md | 9 +- .agents/notes/archived/manifest.json | 3 + ...9-01-jobs-absorb-activity-record.i18n.yaml | 4 +- .../2026-09-01-jobs-absorb-activity-record.md | 3 +- ...26-09-01-jobs-absorb-activity-record.zh.md | 3 +- ...08-08-web-background-job-display.i18n.yaml | 4 +- .../2026-08-08-web-background-job-display.md | 4 +- ...026-08-08-web-background-job-display.zh.md | 4 +- ...-08-24-activity-observation-seam.i18n.yaml | 4 +- .../2026-08-24-activity-observation-seam.md | 4 +- ...2026-08-24-activity-observation-seam.zh.md | 4 +- .../2026-08-25-unified-task-list.i18n.yaml | 6 -- docs/subsystems/jobs.i18n.yaml | 4 +- docs/subsystems/jobs.md | 102 ++++++++++-------- docs/subsystems/jobs.zh.md | 102 ++++++++++-------- .../job-controller/tests/observe.host.spec.ts | 8 +- packages/client/ui-jobs/src/client/index.ts | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 18 +++- packages/jobs/jobs-local/src/index.ts | 39 +++---- packages/jobs/jobs-local/tests/jobs.spec.ts | 22 ++-- packages/jobs/jobs/README.i18n.yaml | 4 +- packages/jobs/jobs/README.md | 4 +- packages/jobs/jobs/README.zh.md | 4 +- packages/jobs/jobs/src/index.ts | 4 +- packages/jobs/jobs/src/pump.ts | 4 +- packages/jobs/jobs/src/types.ts | 81 +++++++++----- packages/jobs/jobs/tests/service.spec.ts | 42 +++++++- packages/shell/tool-bash/src/observe.ts | 4 +- .../shell/tool-bash/tests/observe.spec.ts | 6 +- packages/shell/tool-pwsh/src/observe.ts | 4 +- scripts/gen-cordis-catalog.ts | 3 + scripts/type-equiv.manifest.json | 15 +++ snapshots/web/cordis-tool-round/session.jsonl | 2 +- 35 files changed, 326 insertions(+), 212 deletions(-) create mode 100644 .agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-08-25-unified-task-list.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-08-25-unified-task-list.zh.md (94%) delete mode 100644 .agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml diff --git a/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml new file mode 100644 index 0000000000..c5b051c49c --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-08-25-unified-task-list.md +2026-08-25-unified-task-list.md: f2f955e36a78eee8fa8ff8a804293bcc37a44acd +2026-08-25-unified-task-list.zh.md: 5a4c51a6cc3d0f331d2559debb73dc02e9cf12c5 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md b/.agents/notes/archived/feature/2026-08-25-unified-task-list.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-25-unified-task-list.md rename to .agents/notes/archived/feature/2026-08-25-unified-task-list.md index daf6582bf2..f2f955e36a 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.md @@ -1,11 +1,12 @@ # Agent Note: One session-header task list — jobs joined with activities in the UI projection only Status: implemented - -Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). +Archived: 2026-09-03 English | [中文](2026-08-25-unified-task-list.zh.md) +Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). + ## Problem The [activity observation seam](2026-08-24-activity-observation-seam.md) shipped its Web surface as a second session-header button beside the [background-job list](2026-08-08-web-background-job-display.md). A background bash run registers in both planes — `ctx.jobs` for control and `ctx.activities` for observation — so the same work rendered twice: once in "background jobs" with lifecycle and duration, once in "activities" with expandable live output. Two near-identical popovers with different names for overlapping row sets read as a product accident, and the flat activity rows (live and settled interleaved, single-line, no duration) had no visual hierarchy. diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md similarity index 94% rename from .agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md rename to .agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md index 624747396e..5a4c51a6cc 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md @@ -1,11 +1,12 @@ -# Agent Note:会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并 +# Agent Note: 会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并 -状态:implemented - -Superseded:本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 +Status: implemented +Archived: 2026-09-03 [English](2026-08-25-unified-task-list.md) | 中文 +Superseded: 本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 + ## 问题 [activity 观察面](2026-08-24-activity-observation-seam.zh.md)的 Web 呈现落地为[后台任务列表](2026-08-08-web-background-job-display.zh.md)旁边的第二个会话头部按钮。一次后台 bash 会同时登记在两个面里——`ctx.jobs` 负责控制、`ctx.activities` 负责观察——于是同一份工作渲染了两次:一次在"后台任务"里带生命周期与时长,一次在"活动"里带可展开的实时输出。两个行集合高度重叠、名字却不同的近似弹层,读起来像产品事故;而活动列表的扁平行(live 与 settled 混排、单行、无时长)也缺乏视觉层次。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index e2bf423d56..f4cae45ef7 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -328,6 +328,9 @@ "feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml": "sha256:c2cb839ed676040ed62153c2aab65b677fa59739f69253af50246e79a2347620", "feature/2026-08-19-high-cache-hit-decimal-display.md": "sha256:08cb68bfc379da47a05b816afac26126146d36d6248350d4380f7cb98607d573", "feature/2026-08-19-high-cache-hit-decimal-display.zh.md": "sha256:9d7afe3e2fc3029fbccc643b432a01bcb3b5671750adb6945ac414ec843ca063", + "feature/2026-08-25-unified-task-list.i18n.yaml": "sha256:fec0d4a3ecf59aafd440bc801c686b14d63152c937f51bebe0da61ff615f6f33", + "feature/2026-08-25-unified-task-list.md": "sha256:30bd4e338a3de2f59af98d512c7878928c61846e6700d6012fc45e51c0bf94df", + "feature/2026-08-25-unified-task-list.zh.md": "sha256:88fce0a52b5387ea98c1f68742c03b2f475c5465c680befb32c587066e52cfce", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml index f7cf077333..e895f7c65b 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md -2026-09-01-jobs-absorb-activity-record.md: c1a668f8a008948957e62b5acf1d9db141441885 -2026-09-01-jobs-absorb-activity-record.zh.md: a23f3cda4c8c53663a6aee99469297d6878539d1 +2026-09-01-jobs-absorb-activity-record.md: 2f2bf7a65b5c26c80ec7ddc307c0f31a87f4de54 +2026-09-01-jobs-absorb-activity-record.zh.md: ed62a04e8f9da9de35bd721cceee3a2e56644cdb diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md index c1a668f8a0..2f2bf7a65b 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -14,7 +14,7 @@ The one activity without a job was the foreground workflow mirror. Everything el `ctx.jobs` owns the observation record; `packages/activity/`, `packages/api/activity-controller`, and the correlation vocabulary are removed. -- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops. +- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` for a `record: true` start, the plain `RunningJob { id, updateDetail(detail) }` otherwise — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops. - **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented. - **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design. - **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it. @@ -38,6 +38,7 @@ Review of the merged design settled five points that the sections above leave im - **Client disposal waits for carrier quiescence.** The `ctx.jobOutput` effect disposer is async and awaits every open `RemoteStream.dispose()`, because Cordis awaits async disposers and a fiber that reported unloaded while an old iterator was still closing could overlap the next plugin instance under HMR. - **The roster flags a record, it does not count bytes.** `SessionJob.record: true` replaced `outputTotal`: `append` commits no roster change (only lifecycle commits fire `onJobsChanged`), so a mirrored count went stale the moment output arrived, and broadcasting the roster per append was rejected as traffic for a number no row needed. The live offsets ride the observation stream's `opened` anchor. - **The record is a best-effort live preview.** Producers copy stdout and stderr per poll round, so writes inside one window land stdout first; pipe capture cannot recover the true interleaving (only a PTY or `2>&1` can), and the client concatenates chunks regardless of `channel`. `channel` stays on the wire because it is what makes that limit visible and what a distinct stderr rendering would need. +- **The declaration types the face.** `JobStart` is a union discriminated by `record`: `RecordingJobStart.run` receives `RecordingJob`, `PlainJobStart.run` receives `RunningJob` without `append`, and the registry builds the plain face without the method — output the registry has nowhere to keep is unrepresentable instead of warned about and dropped. - **Web e2e keep-alive is a barrier, not a clock.** The background command holds on a file the test owns in the job's cwd and the scenario kills it explicitly, so no CI stall can settle the job first; each scenario is one test, never a chain of tests sharing a job id. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md index a23f3cda4c..ed62a04e8f 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -14,7 +14,7 @@ Status: implemented `ctx.jobs` 拥有观测 record;`packages/activity/`、`packages/api/activity-controller` 与 correlation 词表被移除。 -- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。 +- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`record: true` 的 start 收到 `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`,其余收到普通的 `RunningJob { id, updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。 - **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。 - **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。 - **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。 @@ -38,6 +38,7 @@ Status: implemented - **客户端卸载等待载体静止。** `ctx.jobOutput` 的 effect disposer 是异步的,await 每条打开的 `RemoteStream.dispose()`:Cordis 会 await 异步 disposer,而一个在旧 iterator 仍在关闭时就宣告卸载完成的 fiber,在 HMR 下可能与下一个插件实例重叠。 - **名册标记 record,不计字节。** `SessionJob.record: true` 取代了 `outputTotal`:`append` 不提交名册变更(只有生命周期提交触发 `onJobsChanged`),镜像的计数在输出到达的瞬间就陈旧了;而为一个没有任何行需要的数字按 append 广播整份名册被否决。实时偏移随观测流的 `opened` 锚帧到达。 - **record 是尽力而为的实时预览。** 生产者按轮询轮次复制 stdout 与 stderr,同一窗口内的写入先 stdout 落地;pipe 捕获无法还原真实交错(只有 PTY 或 `2>&1` 可以),客户端拼接 chunk 时也不区分 `channel`。`channel` 留在 wire 上,因为它正是让这一限制可见、也是将来区分渲染 stderr 所需要的东西。 +- **声明决定生产者面的类型。** `JobStart` 是按 `record` 判别的联合类型:`RecordingJobStart.run` 收到 `RecordingJob`,`PlainJobStart.run` 收到没有 `append` 的 `RunningJob`,注册表给普通 job 构造的面根本没有这个方法——注册表无处存放的输出成为不可表达,而不是警告后丢弃。 - **Web e2e 的保活是 barrier 而非时钟。** 后台命令守在测试拥有的、位于 job cwd 的文件上,由场景显式 kill,因此 CI 卡顿不可能先把任务结算掉;每个场景是一个测试,绝不是共享 job id 的测试链。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 444bbac0ad..ee733d059e 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 3dd003c0e972ec7a5857e84980520eee93b53955 -2026-08-08-web-background-job-display.zh.md: 5d1ee4bada9c1966e4329d35f5a4db64300ca3b4 +2026-08-08-web-background-job-display.md: 7787466154d65ba0970010c3fdaf964ae6d8507f +2026-08-08-web-background-job-display.zh.md: 478acdf00936baed1700d18db2f63b1da1749e37 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 3dd003c0e9..7787466154 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -66,7 +66,7 @@ The listener is owner-granular rather than task-granular. The only consumer push `onJobDone` is not a subset of this. It delivers the terminal record with the exact owner `Agent` under first-wins semantics that `dsh-tool-jobs` couples to `reported`; `onJobsChanged` is pure observation with no delivery meaning and marks nothing reported. Listener throws are contained and never awaited, matching `onJobDone`, and each registration is an effect on the calling fiber. -Service disposal deliberately announces nothing. Every `onJobsChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set. +Service disposal commits one final emptying and announces it: after the registry has cancelled and awaited its jobs, `onJobsChanged` fires once per owner whose visible set the teardown emptied, so a listener registered on a longer-lived fiber sees the final empty set instead of a stale roster (a listener registered on the registry's own fiber is already gone by then). ### The Session Controller carrier @@ -89,7 +89,7 @@ Two replacement points keep it honest. Each control-stream generation clears the ### The header action -`@deepseek-ai/dsh-client-ui-jobs` (since merged into `dsh-client-ui-activity`) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. +`@deepseek-ai/dsh-client-ui-jobs` registers one entry in `conversation.session.header.actions`, ordered between the preset label and the subagent catalog (`order: 20` against the catalog's 30). Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. A running one-shot background subagent therefore appears both there and in the subagent catalog. The two answer different questions — the catalog navigates into the child's transcript, this list is the only handle a cancellation can ever attach to — and suppressing `kind: 'subagent'` here would leave the cancellation phase with no entry point for exactly those tasks. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 5d1ee4bada..478acdf009 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -66,7 +66,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void `onJobDone` 不是它的子集。后者按 first-wins 语义投递终态记录和确切的 owner `Agent`,`dsh-tool-jobs` 把这套语义与 `reported` 绑在一起;`onJobsChanged` 是纯观察,不含任何投递含义,也不把任何东西标为已上报。监听器抛错被包住且从不 await,与 `onJobDone` 一致,每次注册都是调用方 fiber 上的 effect。 -服务销毁刻意什么都不通告。每个 `onJobsChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。 +服务销毁会提交最后一次清空并通告它:注册表取消并等完自己的 job 之后,`onJobsChanged` 对每个可见集被 teardown 清空的 owner 触发一次,因此注册在更长命 fiber 上的监听器看到的是最终空集而不是陈旧名册(注册在注册表自身 fiber 上的监听器此时早已消失)。 ### Session Controller 载体 @@ -89,7 +89,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void ### header 入口 -`@deepseek-ai/dsh-client-ui-jobs`(后并入 `dsh-client-ui-activity`) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 +`@deepseek-ai/dsh-client-ui-jobs` 在 `conversation.session.header.actions` 注册一个条目,排在 preset 标签与 subagent 目录之间(`order: 20`,目录为 30)。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 `kind: 'subagent'` 会让中断那一期恰好对这批任务没有入口。 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml index 651ef99e3c..548e9f5b36 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md -2026-08-24-activity-observation-seam.md: 29ee08b1bb7f72a63d2f84278af62587060de3fe -2026-08-24-activity-observation-seam.zh.md: 97ac4f195c83cdd4f16d2f0a108abd2b3338d95d +2026-08-24-activity-observation-seam.md: 21fe08fd21ad95eec7a83f6f29b9b94514358bb9 +2026-08-24-activity-observation-seam.zh.md: ffb6766b3e5d226d324ea2894a6e3f758cf1932a diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md index 29ee08b1bb..21fe08fd21 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md @@ -19,7 +19,7 @@ A new capability seam, `packages/activity/`, is the non-consuming observation pl - **`@deepseek-ai/dsh-activity` (Service Definition)** — abstract `ActivityRegistry` on `ctx.activities`: `open(spec) → ActivityHandle { append, updateDetail, end }`, fenced `get`/`list`, non-consuming `read(id, from)` over absolute UTF-8 byte offsets, owner-relative `onActivitiesChanged` (roster) and `onOutput` (advancement signal, id only). `ActivityCorrelation { callId?, jobId? }` links a row to its tool call and job. `pumpActivityOutput` is the shared pull-substrate pump. - **`@deepseek-ai/dsh-activity-local` (Service Provider)** — in-memory chunk ring per activity. Offsets stay absolute across eviction (`outputEarliest` marks the oldest retained byte; a read below it is `lossy`, never an error); a single over-cap chunk keeps its UTF-8-safe tail with `gapBefore`. Config `retainBytes` (256 KiB) live, trimmed to `settledRetainBytes` (16 KiB) at settlement. Records outlive producer fibers; owner disposal force-ends and removes; `end` is first-wins and post-end writes log-and-drop so a producer's trailing flush cannot break its own teardown. - **`@deepseek-ai/dsh-api-activity-controller`** — the wire, in the workspace-controller shape: `activity.control` streams one roster baseline then whole-bucket replacement frames per owner session (the jobs-frame self-healing semantics), and `activity.observe({ activityId, from? })` streams one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget), then the terminal `status` on the same stream before closing — so settlement can never race a still-open output channel. Reconnects resume from the last frame's `next`. The client half installs `ctx.activityFeed` (roster mirror plus reference-counted observation streams with a bounded render tail). -- **`@deepseek-ai/dsh-client-ui-activity`** — the session-header task list ([unified with the job rows](2026-08-25-unified-task-list.md)); expanding a row opens its observation stream into a `TerminalBlock` panel, collapsing closes it, so output only flows while someone is watching. `TerminalBlock` itself gained live rendering: a running block with supplied `output` shows the text under the running state instead of the historical prompt-only frame. +- **`@deepseek-ai/dsh-client-ui-activity`** — the session-header task list ([unified with the job rows](../../archived/feature/2026-08-25-unified-task-list.md)); expanding a row opens its observation stream into a `TerminalBlock` panel, collapsing closes it, so output only flows while someone is watching. `TerminalBlock` itself gained live rendering: a running block with supplied `output` shows the text under the running state instead of the historical prompt-only frame. Producers mirror best-effort behind `ctx.get('activities')`, never a declared inject, and every observation failure is logged and swallowed — the seam is strictly optional and can never break the work it observes: @@ -50,4 +50,4 @@ Unit suites pin the registry (offsets, eviction, UTF-8-safe tail trims, lossy re ## Consequences -The Web client now shows live output for background bash/pwsh and workflow narration with zero model-facing change, and any plugin can join with three calls (`open`/`append`/`end`) against an optional service. Bought at the cost of one more seam family and a per-producer tap; the initial two-header-list duplication was folded into [one task list](2026-08-25-unified-task-list.md). Deferred, recorded on the package READMEs: foreground streaming (needs a shell-seam run/observe variant), terminal `pty-send` observation (needs a non-consuming face on terminal buffers), a human kill control (blocked on the jobs `reported` contract), SDK/ACP consumers, and durable replay after restart. +The Web client now shows live output for background bash/pwsh and workflow narration with zero model-facing change, and any plugin can join with three calls (`open`/`append`/`end`) against an optional service. Bought at the cost of one more seam family and a per-producer tap; the initial two-header-list duplication was folded into [one task list](../../archived/feature/2026-08-25-unified-task-list.md). Deferred, recorded on the package READMEs: foreground streaming (needs a shell-seam run/observe variant), terminal `pty-send` observation (needs a non-consuming face on terminal buffers), a human kill control (blocked on the jobs `reported` contract), SDK/ACP consumers, and durable replay after restart. diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md index 97ac4f195c..ffb6766b3e 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md @@ -19,7 +19,7 @@ bash 之下的底座其实早有正确形态:`SubprocessOutputReader.readFrom( - **`@deepseek-ai/dsh-activity`(Service Definition)**——`ctx.activities` 上的抽象 `ActivityRegistry`:`open(spec) → ActivityHandle { append, updateDetail, end }`、有栅栏的 `get`/`list`、按绝对 UTF-8 字节 offset 的非消费 `read(id, from)`、owner 相对投递的 `onActivitiesChanged`(roster)与 `onOutput`(推进信号,只带 id)。`ActivityCorrelation { callId?, jobId? }` 把行关联到工具调用与 job。`pumpActivityOutput` 是 pull 型底座的共享泵。 - **`@deepseek-ai/dsh-activity-local`(Service Provider)**——每个 activity 一个内存 chunk 环。offset 跨淘汰稳定(`outputEarliest` 指明最老保留字节;低于它的读取是 `lossy` 而非错误);单个超上限 chunk 保留 UTF-8 安全尾部并带 `gapBefore`。配置 `retainBytes`(256 KiB)存活,结算时裁到 `settledRetainBytes`(16 KiB)。记录存续期长于生产者 fiber;owner 销毁强制终结并移除;`end` first-wins,end 后写入记日志后丢弃,生产者的收尾 flush 砸不掉自己的 teardown。 - **`@deepseek-ai/dsh-api-activity-controller`**——wire 层,取 workspace-controller 的形态:`activity.control` 流出一份 roster baseline 加按 owner 会话的整桶替换帧(jobs 帧的自愈语义);`activity.observe({ activityId, from? })` 流出一帧 `opened` 锚点、合并的 `output` 帧(`flushMs` 窗口、`maxFrameBytes` 软预算)、再在同一条流上发终态 `status` 后关闭——结算永远不会与仍开着的输出通道竞态。重连以上一帧的 `next` 续传。客户端半区安装 `ctx.activityFeed`(roster 镜像 + 按引用计数的观察流与有界渲染尾巴)。 -- **`@deepseek-ai/dsh-client-ui-activity`**——会话头部任务列表([与 job 行合并](2026-08-25-unified-task-list.zh.md));展开某行即把其观察流打开进 `TerminalBlock` 面板,收起即关——只有有人在看时输出才流动。`TerminalBlock` 本身获得实时渲染能力:running 且提供了 `output` 的块在 running 状态下显示文本,而非历史上的仅提示行画面。 +- **`@deepseek-ai/dsh-client-ui-activity`**——会话头部任务列表([与 job 行合并](../../archived/feature/2026-08-25-unified-task-list.md));展开某行即把其观察流打开进 `TerminalBlock` 面板,收起即关——只有有人在看时输出才流动。`TerminalBlock` 本身获得实时渲染能力:running 且提供了 `output` 的块在 running 状态下显示文本,而非历史上的仅提示行画面。 生产者一律经 `ctx.get('activities')` 尽力镜像,绝不声明 inject,一切观察失败只记日志并吞掉——该 seam 严格可选,永远不能破坏它所观察的工作: @@ -50,4 +50,4 @@ bash 之下的底座其实早有正确形态:`SubprocessOutputReader.readFrom( ## 后果 -Web 客户端在零模型面改动下获得后台 bash/pwsh 实时输出与 workflow 叙述;任何插件对着一个可选服务用三个调用(`open`/`append`/`end`)即可接入。代价是多一个 seam 家族与每生产者一个接入点;最初的双头部列表重复已折并为[单一任务列表](2026-08-25-unified-task-list.zh.md)。暂缓项记录在各包 README:前台流式(需 shell seam 的 run/observe 变体)、terminal `pty-send` 观察(需给 terminal 缓冲加非消费面)、人工 kill 控件(被 jobs `reported` 契约阻塞)、SDK/ACP 消费端、重启后的持久回放。 +Web 客户端在零模型面改动下获得后台 bash/pwsh 实时输出与 workflow 叙述;任何插件对着一个可选服务用三个调用(`open`/`append`/`end`)即可接入。代价是多一个 seam 家族与每生产者一个接入点;最初的双头部列表重复已折并为[单一任务列表](../../archived/feature/2026-08-25-unified-task-list.md)。暂缓项记录在各包 README:前台流式(需 shell seam 的 run/observe 变体)、terminal `pty-send` 观察(需给 terminal 缓冲加非消费面)、人工 kill 控件(被 jobs `reported` 契约阻塞)、SDK/ACP 消费端、重启后的持久回放。 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml deleted file mode 100644 index eab6f6fa1b..0000000000 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-unified-task-list.md -2026-08-25-unified-task-list.md: daf6582bf2c9e4b4a5ce26d6ae632fb78b30dbcb -2026-08-25-unified-task-list.zh.md: 624747396e175a216047239333575322ec4b1808 diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 022befcbd2..8e4c26b4bb 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/jobs.md -jobs.md: 0610d99892da22a25590ab7efe8ff71d9a004f01 -jobs.zh.md: 4c35fd7c879ddf7133c0db55df24948b40118b9d +jobs.md: 639faa8da3836cb3efc865c494cd8ca8c60f49fb +jobs.zh.md: 3d008671657847520eccd41b8a3bd75014777680 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 0610d99892..639faa8da3 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -23,42 +23,37 @@ interface JobKindMap { ## Producer contract -`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` with the job's producer face and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. +`JobStart` declares identity and a starter, discriminated by `record`: a `RecordingJobStart` receives the `RecordingJob` face, a `PlainJobStart` the `RunningJob` face without `append`. The runtime finishes preflight before calling `run()` with that face and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. ```ts type-equiv /** - * Producer declaration passed to {@link JobRegistry.start}. The runtime - * preflights access and cleanup before invoking {@link run}; the producer owns - * execution resources while the runtime owns identity and lifecycle state. + * A start with the model-facing surfaces only. Its producer face carries no + * `append`, so output the registry would have nowhere to keep is + * unrepresentable rather than dropped. */ -interface JobStart { - /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ - kind: JobKind - /** One-line model-facing label (the command; the delegation description). */ - label: string +interface PlainJobStart extends JobStartBase { + record?: undefined /** - * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. Independent of record - * retention: it bounds the consuming model surface, never - * {@link JobRegistry.readRecord}. + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the live-detail writer. */ - outputLimitBytes?: number - /** - * Owning live agent. Access is fenced by its session id, and agent disposal - * cancels and awaits the job. The instance must be the one currently - * registered under its agent id. Omitting the owner creates an unowned job, - * open to any caller until service disposal. - */ - owner?: Agent - /** - * Declares an observable output record beside the model-facing surfaces: - * {@link RunningJob.append} retains chunks in a bounded ring that any number - * of observers read at absolute byte offsets through - * {@link JobRegistry.readRecord}, and snapshots carry - * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. - * Without the declaration `append` logs and drops. - */ - record?: true + run(job: RunningJob): JobHooks +} +``` + +```ts type-equiv +/** + * A start that declares an observable output record beside the model-facing + * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that + * any number of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + */ +interface RecordingJobStart extends JobStartBase { + record: true /** * Start the work after preflight and synchronously return its hooks. Called * once with the job's producer face; a throw leaves nothing registered (the @@ -66,10 +61,20 @@ interface JobStart { * started resources. * @param job - the issued id plus the record append and live-detail writers. */ - run(job: RunningJob): JobHooks + run(job: RecordingJob): JobHooks } ``` +```ts type-equiv +/** + * Producer declaration passed to {@link JobRegistry.start}, discriminated by + * `record`. The runtime preflights access and cleanup before invoking `run`; + * the producer owns execution resources while the runtime owns identity and + * lifecycle state. + */ +type JobStart = PlainJobStart | RecordingJobStart +``` + `JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. Optional `readOutput` distinguishes consuming stream jobs from final-output-only jobs. ```ts type-equiv @@ -113,34 +118,38 @@ interface JobOutcome { ## Observation record -A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. Browsers reach the record through `job.observe`, the Remote stream of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below. +A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RecordingJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. Browsers reach the record through `job.observe`, the Remote stream of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below. ```ts type-equiv /** - * Producer face of one registered job, handed to {@link JobStart.run} and - * valid for the job's whole life. All methods are synchronous. Writes staged - * inside the starter call are retained and become visible with the - * registration commit; after settlement — the producer's own outcome, a kill, - * or a registry-forced teardown end — both methods log and drop instead of - * throwing, so a producer's trailing flush cannot break its own teardown path. + * Producer face of one registered job, handed to `run` and valid for the + * job's whole life. All methods are synchronous. Writes staged inside the + * starter call are retained and become visible with the registration commit; + * after settlement — the producer's own outcome, a kill, or a registry-forced + * teardown end — writes log and drop instead of throwing, so a producer's + * trailing flush cannot break its own teardown path. */ interface RunningJob { /** The registry-issued id (`-N`). */ readonly id: JobId + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * @param detail - the new detail line. + */ + updateDetail(detail: string): void +} +``` + +```ts type-equiv +/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */ +interface RecordingJob extends RunningJob { /** * Append one record chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. Without a - * {@link JobStart.record} declaration the chunk is logged and dropped. + * length; an empty chunk is dropped without waking observers. * @param text - the chunk text, exactly as produced. * @param options - stream label and gap marker. */ append(text: string, options?: JobAppendOptions): void - /** - * Replace the snapshot's status detail with a live progress line (`3/10`). - * Works for every job, with or without a record. - * @param detail - the new detail line. - */ - updateDetail(detail: string): void } ``` @@ -368,7 +377,6 @@ abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal */ abstract onJobDone(listener: JobDoneListener): () => void -/** /** * Register an effect-scoped observer of visible-set changes. It fires after * every commit that changes what {@link list} returns for that owner — diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 4c35fd7c87..3d00867165 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -23,42 +23,37 @@ interface JobKindMap { ## 生产方约定 -`JobStart` 声明身份和启动器。运行时会在完成预检后携带该 job 的生产者面调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 +`JobStart` 声明身份和启动器,并按 `record` 判别:`RecordingJobStart` 收到 `RecordingJob` 面,`PlainJobStart` 收到没有 `append` 的 `RunningJob` 面。运行时会在完成预检后携带该面调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 ```ts type-equiv /** - * Producer declaration passed to {@link JobRegistry.start}. The runtime - * preflights access and cleanup before invoking {@link run}; the producer owns - * execution resources while the runtime owns identity and lifecycle state. + * A start with the model-facing surfaces only. Its producer face carries no + * `append`, so output the registry would have nowhere to keep is + * unrepresentable rather than dropped. */ -interface JobStart { - /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ - kind: JobKind - /** One-line model-facing label (the command; the delegation description). */ - label: string +interface PlainJobStart extends JobStartBase { + record?: undefined /** - * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. Independent of record - * retention: it bounds the consuming model surface, never - * {@link JobRegistry.readRecord}. + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the live-detail writer. */ - outputLimitBytes?: number - /** - * Owning live agent. Access is fenced by its session id, and agent disposal - * cancels and awaits the job. The instance must be the one currently - * registered under its agent id. Omitting the owner creates an unowned job, - * open to any caller until service disposal. - */ - owner?: Agent - /** - * Declares an observable output record beside the model-facing surfaces: - * {@link RunningJob.append} retains chunks in a bounded ring that any number - * of observers read at absolute byte offsets through - * {@link JobRegistry.readRecord}, and snapshots carry - * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. - * Without the declaration `append` logs and drops. - */ - record?: true + run(job: RunningJob): JobHooks +} +``` + +```ts type-equiv +/** + * A start that declares an observable output record beside the model-facing + * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that + * any number of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + */ +interface RecordingJobStart extends JobStartBase { + record: true /** * Start the work after preflight and synchronously return its hooks. Called * once with the job's producer face; a throw leaves nothing registered (the @@ -66,10 +61,20 @@ interface JobStart { * started resources. * @param job - the issued id plus the record append and live-detail writers. */ - run(job: RunningJob): JobHooks + run(job: RecordingJob): JobHooks } ``` +```ts type-equiv +/** + * Producer declaration passed to {@link JobRegistry.start}, discriminated by + * `record`. The runtime preflights access and cleanup before invoking `run`; + * the producer owns execution resources while the runtime owns identity and + * lifecycle state. + */ +type JobStart = PlainJobStart | RecordingJobStart +``` + `JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 ```ts type-equiv @@ -113,34 +118,38 @@ interface JobOutcome { ## 观测 record -声明 `record: true` 的生产方通过 starter 收到的 `RunningJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。浏览器经 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.observe` 读取 record,其帧类型列在下方该控制器的 Cordis API 一节。 +声明 `record: true` 的生产方通过 starter 收到的 `RecordingJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。浏览器经 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.observe` 读取 record,其帧类型列在下方该控制器的 Cordis API 一节。 ```ts type-equiv /** - * Producer face of one registered job, handed to {@link JobStart.run} and - * valid for the job's whole life. All methods are synchronous. Writes staged - * inside the starter call are retained and become visible with the - * registration commit; after settlement — the producer's own outcome, a kill, - * or a registry-forced teardown end — both methods log and drop instead of - * throwing, so a producer's trailing flush cannot break its own teardown path. + * Producer face of one registered job, handed to `run` and valid for the + * job's whole life. All methods are synchronous. Writes staged inside the + * starter call are retained and become visible with the registration commit; + * after settlement — the producer's own outcome, a kill, or a registry-forced + * teardown end — writes log and drop instead of throwing, so a producer's + * trailing flush cannot break its own teardown path. */ interface RunningJob { /** The registry-issued id (`-N`). */ readonly id: JobId + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * @param detail - the new detail line. + */ + updateDetail(detail: string): void +} +``` + +```ts type-equiv +/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */ +interface RecordingJob extends RunningJob { /** * Append one record chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. Without a - * {@link JobStart.record} declaration the chunk is logged and dropped. + * length; an empty chunk is dropped without waking observers. * @param text - the chunk text, exactly as produced. * @param options - stream label and gap marker. */ append(text: string, options?: JobAppendOptions): void - /** - * Replace the snapshot's status detail with a live progress line (`3/10`). - * Works for every job, with or without a record. - * @param detail - the new detail line. - */ - updateDetail(detail: string): void } ``` @@ -368,7 +377,6 @@ abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal */ abstract onJobDone(listener: JobDoneListener): () => void -/** /** * Register an effect-scoped observer of visible-set changes. It fires after * every commit that changes what {@link list} returns for that owner — diff --git a/packages/api/job-controller/tests/observe.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts index 5bbb5a8c66..bc6827fdc2 100644 --- a/packages/api/job-controller/tests/observe.host.spec.ts +++ b/packages/api/job-controller/tests/observe.host.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobOutcome, RunningJob } from '@deepseek-ai/dsh-jobs' +import type { JobOutcome, RecordingJob } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -38,13 +38,13 @@ function registerAgent(ctx: Context, rawId: string): Agent { /** Start one record job whose done settles on demand; expose the producer face. */ function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; record?: boolean } = {}) { let settle!: (outcome: JobOutcome) => void - let face!: RunningJob + let face!: RecordingJob const id = ctx.jobs.start({ kind: 'bash', label: options.label ?? 'echo', ...options.owner !== undefined ? { owner: options.owner } : {}, ...options.record === false ? {} : { record: true }, - run: (job) => { + run: (job: RecordingJob) => { face = job return { cancel() {}, @@ -54,7 +54,7 @@ function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; }) return { id, - append: (text: string, opts?: Parameters[1]) => { face.append(text, opts) }, + append: (text: string, opts?: Parameters[1]) => { face.append(text, opts) }, updateDetail: (detail: string) => { face.updateDetail(detail) }, settle: async (outcome: JobOutcome) => { settle(outcome) diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index 209cec1f23..3edaf89efb 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -38,7 +38,8 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'job-list', - // After the subagent catalog: session lineage reads before running work. + // Between the preset label and the subagent catalog (order 30): running + // work reads before the session lineage. order: 20, locale: NS, inject: (): JobListInjected => ({ diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 3002c82a56..ff56058008 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1095,7 +1095,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract onJobsChanged(listener: JobsChangedListener): () => void', - description: '/** Register an effect-scoped observer of visible-set changes. It fires after every commit that changes what list returns for that owner — registration, every stopping transition (including the one teardown performs before it awaits a slow producer), settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas.\n\nDelivery is owner-relative on the same terms as onJobDone: an observer registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it.\n\nThis is not a superset of onJobDone: that one delivers the terminal record under first-wins semantics a job controller couples to notice delivery, while this one carries no delivery meaning and marks nothing reported. Listeners are contained and never awaited.', + description: 'Register an effect-scoped observer of visible-set changes. It fires after every commit that changes what list returns for that owner — registration, every stopping transition (including the one teardown performs before it awaits a slow producer), settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas.\n\nDelivery is owner-relative on the same terms as onJobDone: an observer registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it.\n\nThis is not a superset of onJobDone: that one delivers the terminal record under first-wins semantics a job controller couples to notice delivery, while this one carries no delivery meaning and marks nothing reported. Listeners are contained and never awaited.', parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned job changed and every caller\'s set did.' }], returns: 'disposer that unregisters the listener.', }, @@ -4239,7 +4239,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'JobStart', - declaration: 'export interface JobStart {\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n record?: true;\n run(job: RunningJob): JobHooks;\n}', + declaration: 'export type JobStart = PlainJobStart | RecordingJobStart;', }, { name: 'JobStatus', @@ -4537,6 +4537,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PermissionSelect', declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}', }, + { + name: 'PlainJobStart', + declaration: 'export interface PlainJobStart extends JobStartBase {\n record?: undefined;\n run(job: RunningJob): JobHooks;\n}', + }, { name: 'PostToolDecision', declaration: 'export type PostToolDecision = {\n kind: \'accept\';\n content?: ContentBlock[];\n value?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'accept\';\n value: JsonValue;\n content?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'block\';\n feedback: ContentBlock[];\n additionalContexts?: UserMessage[];\n};', @@ -4657,6 +4661,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ReasoningEffortId', declaration: 'export type ReasoningEffortId = Branded<\'ReasoningEffortId\'>;', }, + { + name: 'RecordingJob', + declaration: 'export interface RecordingJob extends RunningJob {\n append(text: string, options?: JobAppendOptions): void;\n}', + }, + { + name: 'RecordingJobStart', + declaration: 'export interface RecordingJobStart extends JobStartBase {\n record: true;\n run(job: RecordingJob): JobHooks;\n}', + }, { name: 'RedactedSecret', declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}', @@ -4739,7 +4751,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RunningJob', - declaration: 'export interface RunningJob {\n readonly id: JobId;\n append(text: string, options?: JobAppendOptions): void;\n updateDetail(detail: string): void;\n}', + declaration: 'export interface RunningJob {\n readonly id: JobId;\n updateDetail(detail: string): void;\n}', }, { name: 'SandboxEnforcement', diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 15cb5336b0..e5415aee15 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -19,7 +19,7 @@ import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' import type { JobAppendOptions, JobChannel, JobDoneListener, JobKind, JobOutcome, JobOutputListener, JobRead, - JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, RunningJob, + JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, RecordingJob, RunningJob, } from '@deepseek-ai/dsh-jobs' /** Timeout code that distinguishes a bounded wait from caller cancellation. */ @@ -236,17 +236,13 @@ export class LocalJobRegistry extends JobRegistry { const count = (this.counters.get(spec.kind) ?? 0) + 1 this.counters.set(spec.kind, count) const id = JobId(`${spec.kind}-${count}`) - const state: ProducerState = { - record: spec.record === true ? { chunks: [], retainedBytes: 0, total: 0, earliest: 0 } : undefined, - detail: undefined, - job: undefined, - } - const handle: RunningJob = { + const state: ProducerState = { record: undefined, detail: undefined, job: undefined } + const face: RunningJob = { id, - append: (text, options) => { this.appendRecord(id, state, text, options) }, updateDetail: (detail) => { this.updateDetail(state, detail) }, } - const hooks = spec.run(handle) + // The declaration decides the face: a plain start never receives `append`. + const hooks = spec.record === true ? spec.run(this.recordingFace(face, state)) : spec.run(face) let markSettled!: () => void const settled = new Promise((resolve) => { markSettled = resolve }) @@ -556,23 +552,28 @@ export class LocalJobRegistry extends JobRegistry { } } + /** Install the job's record and extend the producer face with its append. */ + private recordingFace(face: RunningJob, state: ProducerState): RecordingJob { + const record: RecordState = { chunks: [], retainedBytes: 0, total: 0, earliest: 0 } + state.record = record + return { + ...face, + append: (text, options) => { this.appendRecord(state, record, text, options) }, + } + } + /** - * Append one chunk through a producer face. A chunk against a settled job or - * without a record declaration is logged and dropped; an empty chunk is - * dropped silently. A chunk staged inside the starter call is retained and - * signals no observer — the registration commit publishes it. + * Append one chunk through a recording producer face. A chunk against a + * settled job is logged and dropped; an empty chunk is dropped silently. A + * chunk staged inside the starter call is retained and signals no observer — + * the registration commit publishes it. */ - private appendRecord(id: JobId, state: ProducerState, text: string, options?: JobAppendOptions): void { + private appendRecord(state: ProducerState, record: RecordState, text: string, options?: JobAppendOptions): void { const job = state.job if (job !== undefined && isTerminal(job.status)) { this.selfCtx.logger.warn(`jobs: append to settled job ${job.id} dropped`) return } - const record = state.record - if (record === undefined) { - this.selfCtx.logger.warn(`jobs: append to job ${id} without a record declaration dropped`) - return - } if (text.length === 0) return const bytes = Buffer.byteLength(text, 'utf8') record.chunks.push({ diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 9771023057..5bcb9b156e 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -6,7 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RunningJob } from '@deepseek-ai/dsh-jobs' +import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RecordingJob } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local' declare module '@deepseek-ai/dsh-jobs' { @@ -66,14 +66,14 @@ function producer(overrides: Partial & JobHooks> = {}) { done: new Promise((res, rej) => { settle = res; reject = rej }), ...hookOverrides, } - let started: RunningJob | undefined + let started: RecordingJob | undefined const spec: JobStart = { kind, label, ...owner !== undefined ? { owner } : {}, ...outputLimitBytes !== undefined ? { outputLimitBytes } : {}, ...record !== undefined ? { record } : {}, - run: (job) => { + run: (job: RecordingJob) => { started = job return hooks }, @@ -83,7 +83,7 @@ function producer(overrides: Partial & JobHooks> = {}) { settle, reject, cancels, - job(): RunningJob { + job(): RecordingJob { if (started === undefined) throw new Error('producer not started') return started }, @@ -200,7 +200,7 @@ describe('LocalJobRegistry.start', () => { for (const job of live) ctx.jobs.start(job.spec) const blocked = producer() - const run = vi.fn((job: RunningJob) => blocked.spec.run(job)) + const run = vi.fn((job: RecordingJob) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('background job limit reached for this owner (limit: 10)') expect(run).not.toHaveBeenCalled() @@ -213,7 +213,7 @@ describe('LocalJobRegistry.start', () => { expect(ctx.jobs.start(first.spec)).toBe('bash-1') const blocked = producer() - const run = vi.fn((job: RunningJob) => blocked.spec.run(job)) + const run = vi.fn((job: RecordingJob) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('use job_kill to stop an unneeded job, wait for it to finish, then retry') expect(run).not.toHaveBeenCalled() @@ -658,7 +658,7 @@ describe('LocalJobRegistry owner isolation', () => { ctx.jobs.start(current.spec) // Attach the current owner's cleanup first. const stale = producer({ owner: staleOwner }) - const staleRun = vi.fn((job: RunningJob) => stale.spec.run(job)) + const staleRun = vi.fn((job: RecordingJob) => stale.spec.run(job)) expect(() => ctx.jobs.start({ ...stale.spec, run: staleRun })) .toThrow('is not the registered agent instance') expect(staleRun).not.toHaveBeenCalled() @@ -1223,14 +1223,12 @@ describe('LocalJobRegistry record', () => { expect(ctx.jobs.get(id).outputTotal).toBe(0) }) - it('logs and drops an append without a record declaration; updateDetail still works', async () => { + it('hands a start without a record declaration no append; updateDetail still works', async () => { const ctx = await harness() - const warn = vi.fn() - ctx.logger.warn = warn as never const p = producer() const id = ctx.jobs.start(p.spec) - p.job().append('nowhere to land') - expect(warn).toHaveBeenCalledWith(expect.stringContaining(`append to job ${id} without a record declaration dropped`)) + // The plain face is built without the method, not with a dropping stub. + expect(p.job()).not.toHaveProperty('append') expect(() => ctx.jobs.readRecord(id, 0)).toThrow(`job ${id} declared no output record`) const snapshot = ctx.jobs.get(id) expect(snapshot.outputTotal).toBeUndefined() diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index 662a521a55..54d4ecc8e9 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md -README.md: 10c74af908f64c18616e3c1eb71f19a3cbbf4eb3 -README.zh.md: 1fc500311c58a538ebfc7fbd89886d9c12b96acd +README.md: 5d4e10de9fa852dc4a38c521321bae53c744909b +README.zh.md: b584191a6b94e79ba8908875e7a0706a525edf4f diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index 10c74af908..5d4e10de9f 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -31,7 +31,7 @@ Use this package when you are composing a background-job capability or writing a A producer registers work with a kind and a one-line label; the registry returns a `-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh snapshot of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the owning agent is notified through the completion listener that `dsh-tool-jobs` turns into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing output read or completion notice stays bounded. -A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RunningJob` face its starter receives: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state. +A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RecordingJob` face its starter receives — a start without the declaration receives the plain `RunningJob` face, which has no `append`: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state. ### The ownership boundary @@ -77,7 +77,7 @@ This section explains the design decisions behind the contract and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract | -| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `JobSnapshot`, listener types | +| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `RecordingJob`, `JobSnapshot`, listener types | | [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency | | [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`: poll pump copying producer offset-readers into a job record | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, owner, and record-offset fields | diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index 1fc500311c..b584191a6b 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 生产方以 kind 和一行标签注册工作;注册表返回 `-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新快照,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,拥有它的 agent 会通过 `dsh-tool-jobs` 转成会话内通知的完成监听器得到通知,因此无需轮询。生产方还可以附加可选的字节上限,让每次完整的模型侧输出读取或完成通知保持有界。 -声明 `record: true` 的生产方还会通过 starter 收到的 `RunningJob` 面把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。 +声明 `record: true` 的生产方还会通过 starter 收到的 `RecordingJob` 面(未声明的 start 收到的是没有 `append` 的普通 `RunningJob` 面)把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。 ### 归属边界 @@ -77,7 +77,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 | -| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`JobSnapshot`、监听器类型 | +| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`RecordingJob`、`JobSnapshot`、监听器类型 | | [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 | | [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`:把生产方偏移读取器复制进 job record 的轮询泵 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳、所有者与 record 偏移字段 | diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts index 796afc0882..4b6d6d9d43 100644 --- a/packages/jobs/jobs/src/index.ts +++ b/packages/jobs/jobs/src/index.ts @@ -33,6 +33,9 @@ export type { JobStart, JobStatus, JobsChangedListener, + PlainJobStart, + RecordingJob, + RecordingJobStart, RunningJob, } from './types.ts' export { pumpJobOutput } from './pump.ts' @@ -163,7 +166,6 @@ export abstract class JobRegistry extends Service { */ abstract onJobDone(listener: JobDoneListener): () => void - /** /** * Register an effect-scoped observer of visible-set changes. It fires after * every commit that changes what {@link list} returns for that owner — diff --git a/packages/jobs/jobs/src/pump.ts b/packages/jobs/jobs/src/pump.ts index 5203157a9f..a3c1956019 100644 --- a/packages/jobs/jobs/src/pump.ts +++ b/packages/jobs/jobs/src/pump.ts @@ -5,7 +5,7 @@ * @module */ -import type { JobChannel, RunningJob } from './types.ts' +import type { JobChannel, RecordingJob } from './types.ts' /** One non-consuming offset read from a producer-owned stream. */ export interface JobPumpRead { @@ -74,7 +74,7 @@ export interface JobPumpOptions { * @returns resolves after the final post-settlement drain. */ export async function pumpJobOutput( - job: RunningJob, + job: RecordingJob, sources: readonly JobPumpSource[], options: JobPumpOptions, ): Promise { diff --git a/packages/jobs/jobs/src/types.ts b/packages/jobs/jobs/src/types.ts index b42d7815e4..3ec3f87357 100644 --- a/packages/jobs/jobs/src/types.ts +++ b/packages/jobs/jobs/src/types.ts @@ -56,12 +56,8 @@ export interface JobAppendOptions { gapBefore?: true } -/** - * Producer declaration passed to {@link JobRegistry.start}. The runtime - * preflights access and cleanup before invoking {@link run}; the producer owns - * execution resources while the runtime owns identity and lifecycle state. - */ -export interface JobStart { +/** Fields shared by every producer declaration passed to {@link JobRegistry.start}. */ +interface JobStartBase { /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ kind: JobKind /** One-line model-facing label (the command; the delegation description). */ @@ -80,15 +76,34 @@ export interface JobStart { * open to any caller until service disposal. */ owner?: Agent +} + +/** + * A start with the model-facing surfaces only. Its producer face carries no + * `append`, so output the registry would have nowhere to keep is + * unrepresentable rather than dropped. + */ +export interface PlainJobStart extends JobStartBase { + record?: undefined /** - * Declares an observable output record beside the model-facing surfaces: - * {@link RunningJob.append} retains chunks in a bounded ring that any number - * of observers read at absolute byte offsets through - * {@link JobRegistry.readRecord}, and snapshots carry - * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. - * Without the declaration `append` logs and drops. + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the live-detail writer. */ - record?: true + run(job: RunningJob): JobHooks +} + +/** + * A start that declares an observable output record beside the model-facing + * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that + * any number of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + */ +export interface RecordingJobStart extends JobStartBase { + record: true /** * Start the work after preflight and synchronously return its hooks. Called * once with the job's producer face; a throw leaves nothing registered (the @@ -96,34 +111,44 @@ export interface JobStart { * started resources. * @param job - the issued id plus the record append and live-detail writers. */ - run(job: RunningJob): JobHooks + run(job: RecordingJob): JobHooks } /** - * Producer face of one registered job, handed to {@link JobStart.run} and - * valid for the job's whole life. All methods are synchronous. Writes staged - * inside the starter call are retained and become visible with the - * registration commit; after settlement — the producer's own outcome, a kill, - * or a registry-forced teardown end — both methods log and drop instead of - * throwing, so a producer's trailing flush cannot break its own teardown path. + * Producer declaration passed to {@link JobRegistry.start}, discriminated by + * `record`. The runtime preflights access and cleanup before invoking `run`; + * the producer owns execution resources while the runtime owns identity and + * lifecycle state. + */ +export type JobStart = PlainJobStart | RecordingJobStart + +/** + * Producer face of one registered job, handed to `run` and valid for the + * job's whole life. All methods are synchronous. Writes staged inside the + * starter call are retained and become visible with the registration commit; + * after settlement — the producer's own outcome, a kill, or a registry-forced + * teardown end — writes log and drop instead of throwing, so a producer's + * trailing flush cannot break its own teardown path. */ export interface RunningJob { /** The registry-issued id (`-N`). */ readonly id: JobId + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * @param detail - the new detail line. + */ + updateDetail(detail: string): void +} + +/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */ +export interface RecordingJob extends RunningJob { /** * Append one record chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. Without a - * {@link JobStart.record} declaration the chunk is logged and dropped. + * length; an empty chunk is dropped without waking observers. * @param text - the chunk text, exactly as produced. * @param options - stream label and gap marker. */ append(text: string, options?: JobAppendOptions): void - /** - * Replace the snapshot's status detail with a live progress line (`3/10`). - * Works for every job, with or without a record. - * @param detail - the new detail line. - */ - updateDetail(detail: string): void } /** Hooks through which the runtime controls and observes producer work. */ diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index 3938328478..331bf6596b 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -3,8 +3,8 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { JobId, JobRegistry, pumpJobOutput } from '@deepseek-ai/dsh-jobs' import type { - JobAppendOptions, JobDoneListener, JobOutputListener, JobPumpSource, JobRead, JobRecordRead, - JobSnapshot, JobStart, JobsChangedListener, RunningJob, + JobAppendOptions, JobDoneListener, JobHooks, JobOutputListener, JobPumpSource, JobRead, JobRecordRead, + JobSnapshot, JobStart, JobsChangedListener, PlainJobStart, RecordingJob, RecordingJobStart, } from '@deepseek-ai/dsh-jobs' /** @@ -71,6 +71,40 @@ class StubJobRegistry extends JobRegistry { } } +/** Compile-time probe: does the producer face `Face` carry `append`? */ +type HasAppend = 'append' extends keyof Face ? true : false + +describe('JobStart producer faces', () => { + it('hands append only to a start that declares a record', () => { + const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) } + // The literal's `record` discriminant selects the face `run` receives. + const plain: JobStart = { + kind: 'bash', + label: 'no record', + run: (job) => { + job.updateDetail('works for every job') + return hooks + }, + } + const recording: JobStart = { + kind: 'bash', + label: 'record', + record: true, + run: (job) => { + job.append('lands in the record') + return hooks + }, + } + // Negative proof: a plain start's face has no `append` at the type level. + const plainFace: HasAppend[0]> = false + const recordingFace: HasAppend[0]> = true + expect(plainFace).toBe(false) + expect(recordingFace).toBe(true) + expect(plain.record).toBeUndefined() + expect(recording.record).toBe(true) + }) +}) + describe('JobRegistry seam', () => { it('a concrete subclass registers as ctx.jobs and serves the abstract API', async () => { const ctx = new Context() @@ -109,9 +143,9 @@ describe('JobRegistry seam', () => { }) /** Record every append so pump behavior is observable without a registry. */ -function recordingJob(): { job: RunningJob; appends: { text: string; options?: JobAppendOptions }[] } { +function recordingJob(): { job: RecordingJob; appends: { text: string; options?: JobAppendOptions }[] } { const appends: { text: string; options?: JobAppendOptions }[] = [] - const job: RunningJob = { + const job: RecordingJob = { id: JobId('bash-1'), append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) }, updateDetail() {}, diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts index 0758be7b7b..89a28817bc 100644 --- a/packages/shell/tool-bash/src/observe.ts +++ b/packages/shell/tool-bash/src/observe.ts @@ -15,7 +15,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ShellProcess } from '@deepseek-ai/dsh-shell' import { pumpJobOutput } from '@deepseek-ai/dsh-jobs' -import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs' +import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs' /** * Pump the process's non-consuming offset readers into the job record until @@ -31,7 +31,7 @@ import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs' */ export function observeProcessRecord( ctx: Context, - job: RunningJob, + job: RecordingJob, proc: ShellProcess, pollMs: number, ): Promise { diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts index 8a4ac6ae2a..4586ada7a5 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -10,7 +10,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -import type { JobId, RunningJob } from '@deepseek-ai/dsh-jobs' +import type { JobId, RecordingJob } from '@deepseek-ai/dsh-jobs' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import type { ShellProcess } from '@deepseek-ai/dsh-shell' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -121,8 +121,8 @@ describe('background bash record', () => { }) describe('observeProcessRecord', () => { - /** A RunningJob face recording appends for pump assertions. */ - function recordingJob(): { job: RunningJob; appends: string[] } { + /** A RecordingJob face recording appends for pump assertions. */ + function recordingJob(): { job: RecordingJob; appends: string[] } { const appends: string[] = [] return { job: { diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts index 911145bd93..357567e31e 100644 --- a/packages/shell/tool-pwsh/src/observe.ts +++ b/packages/shell/tool-pwsh/src/observe.ts @@ -16,7 +16,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ShellProcess } from '@deepseek-ai/dsh-shell' import { pumpJobOutput } from '@deepseek-ai/dsh-jobs' -import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs' +import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs' /** * Pump the process's non-consuming offset readers into the job record until @@ -32,7 +32,7 @@ import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs' */ export function observeProcessRecord( ctx: Context, - job: RunningJob, + job: RecordingJob, proc: ShellProcess, pollMs: number, ): Promise { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ac52455c6d..b78e1dcf07 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -513,6 +513,9 @@ export const LINK_MAP: Readonly> = { JobWireChunk: 'jobs.md', JobSnapshot: 'jobs.md', JobStart: 'jobs.md', + PlainJobStart: 'jobs.md', + RecordingJob: 'jobs.md', + RecordingJobStart: 'jobs.md', JobsChangedListener: 'jobs.md', JobAppendOptions: 'jobs.md', JobChannel: 'jobs.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 2c24e9b407..327f58f9e3 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1006,6 +1006,16 @@ "symbol": "JobKindMap", "source": "packages/jobs/jobs/src/types.ts" }, + { + "doc": "docs/subsystems/jobs.md", + "symbol": "PlainJobStart", + "source": "packages/jobs/jobs/src/types.ts" + }, + { + "doc": "docs/subsystems/jobs.md", + "symbol": "RecordingJobStart", + "source": "packages/jobs/jobs/src/types.ts" + }, { "doc": "docs/subsystems/jobs.md", "symbol": "JobStart", @@ -1036,6 +1046,11 @@ "symbol": "RunningJob", "source": "packages/jobs/jobs/src/types.ts" }, + { + "doc": "docs/subsystems/jobs.md", + "symbol": "RecordingJob", + "source": "packages/jobs/jobs/src/types.ts" + }, { "doc": "docs/subsystems/jobs.md", "symbol": "JobRecordChunk", diff --git a/snapshots/web/cordis-tool-round/session.jsonl b/snapshots/web/cordis-tool-round/session.jsonl index e69dbc7f2a..354033a43e 100644 --- a/snapshots/web/cordis-tool-round/session.jsonl +++ b/snapshots/web/cordis-tool-round/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1788357779640,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736473857,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} From 09bd6bcf54a1dbc792fb8b79bac9d20515dd59cc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 13:45:09 +0800 Subject: [PATCH 026/141] chore(http-proxy): align the package version with the 0.1.2-rc.1 bump --- packages/util/http-proxy/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/util/http-proxy/package.json b/packages/util/http-proxy/package.json index b626e431e3..d75ba94f36 100644 --- a/packages/util/http-proxy/package.json +++ b/packages/util/http-proxy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-http-proxy", "description": "Process-wide outbound HTTP proxy policy for DeepSeek Harness: resolve it from the launch environment and install it as undici's global dispatcher", - "version": "0.1.2-alpha.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, From 07941fe5e2a18abd89faa1a09f395dad164a8b66 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 15:51:39 +0800 Subject: [PATCH 027/141] refactor(jobs): consolidate the seam into JobSpec, VisibleJobs, and one event stream The registry seam keeps three members plus the controller handshake: `start(spec)`, `visibleTo(caller)` (one authorization, then list / get / read / readAt / kill / wait without a caller argument), and `events` (one typed stream with an explicit filter instead of three listeners). Every job owns an output ring: `JobSpec.output` names pull sources the registry pumps, `JobHandle.append` pushes, and the model's consuming `read` and observers' `readAt` read the same bytes through separate cursors. `reported`, the `record` declaration, the producer `readOutput` hook, and the `JobStart`/`RunningJob` split are gone; `JobView` is the one projection, split into `progress` and terminal `detail`. The client-safe `./view` leaf carries the projection vocabulary. --- packages/jobs/jobs/package.json | 4 + packages/jobs/jobs/src/index.ts | 207 +++------- packages/jobs/jobs/src/invariant.ts | 53 ++- packages/jobs/jobs/src/pump.ts | 122 ------ packages/jobs/jobs/src/types.ts | 430 +++++++++++---------- packages/jobs/jobs/src/view.ts | 81 ++++ packages/jobs/jobs/tests/invariant.spec.ts | 91 +++-- packages/jobs/jobs/tests/service.spec.ts | 286 ++++---------- tsconfig.base.json | 1 + 9 files changed, 513 insertions(+), 762 deletions(-) delete mode 100644 packages/jobs/jobs/src/pump.ts create mode 100644 packages/jobs/jobs/src/view.ts diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index 34b32d40e7..dfaba10771 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -26,6 +26,10 @@ "types": "./lib/types/brand.d.ts", "default": "./lib/types/brand.js" }, + "./view": { + "types": "./lib/types/view.d.ts", + "default": "./lib/types/view.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts index 4b6d6d9d43..a1c60cbeef 100644 --- a/packages/jobs/jobs/src/index.ts +++ b/packages/jobs/jobs/src/index.ts @@ -1,45 +1,43 @@ /** * The background-job Service Definition (`ctx.jobs`). It owns the contract for - * job ids, session-scoped access, lifecycle state, completion listeners, - * owner cleanup, and the optional per-job observation record — an append-only - * bounded output stream any number of independent observers read at absolute - * byte offsets, invisible to the model-facing consuming cursor — while - * producers retain their execution resources. The process-local registry - * lives in `@deepseek-ai/dsh-jobs-local`. + * job ids, session-scoped access, lifecycle state, the per-job output ring — + * one bounded stream that the model consumes through a registry-kept cursor + * and that any number of observers read at absolute byte offsets — and the + * event stream announcing every commit, while producers retain their + * execution resources. The process-local registry lives in + * `@deepseek-ai/dsh-jobs-local`. * @module @deepseek-ai/dsh-jobs */ import { Context, Service } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { - JobDoneListener, JobId, JobOutputListener, JobRead, JobRecordRead, JobSnapshot, JobStart, - JobsChangedListener, -} from './types.ts' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { JobEvents, JobId, JobSpec, VisibleJobs } from './types.ts' export { JobId } from './types.ts' export type { JobAppendOptions, JobChannel, - JobDoneListener, + JobChunk, + JobEvent, + JobEventFilter, + JobEventListener, + JobEvents, + JobHandle, JobHooks, + JobKillOptions, JobKind, JobKindMap, JobOutcome, - JobOutputListener, + JobOutputRead, + JobOutputSource, JobRead, - JobRecordChunk, - JobRecordRead, - JobSnapshot, - JobStart, + JobSettleCause, + JobSourceRead, + JobSpec, JobStatus, - JobsChangedListener, - PlainJobStart, - RecordingJob, - RecordingJobStart, - RunningJob, + JobView, + VisibleJobs, } from './types.ts' -export { pumpJobOutput } from './pump.ts' -export type { JobPumpOptions, JobPumpRead, JobPumpSource } from './pump.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -48,7 +46,7 @@ declare module '@deepseek-ai/cordis' { } /** - * Abstract background job registry. Subclass, implement the abstract methods, + * Abstract background job registry. Subclass, implement the abstract members, * and load the subclass as a plugin — it registers as `ctx.jobs` (one * implementation per context; loading a second throws, which is cordis' * standard duplicate-service behavior). @@ -56,32 +54,30 @@ declare module '@deepseek-ai/cordis' { * Implementations must honor these semantics: * - Registrations outlive producer and controller fibers. Owner and * service disposal cancel live work and await compliant producers; a - * throwing teardown cancel force-fails only the record. Teardown - * cancellation also marks the record reported, because a record its owner - * is being destroyed for has no reader left. + * throwing teardown cancel force-fails only the record. Such settlements + * announce `cause: 'teardown'`, because a job whose owner is being destroyed + * has no reader left. * - Owned-job access is fenced by the owner's session id. Ids are * predictable, so authorization — not secrecy — is the boundary. - * - Settlement is first-wins: one terminal record, released waiters, and one - * round of contained listener notification, even against a late producer - * outcome. Completion is announced last, after the record is committed and - * every other observer of the settlement has seen it, because a reporter - * may open a model turn synchronously. + * - Settlement is first-wins: one terminal record, released waiters, then one + * round of contained event delivery, even against a late producer outcome. + * The `settled` event follows every released waiter, so a consumer that + * claims a settlement while waiting always claims before the event. * - {@link start} refuses work while no attached job controller serves the * spec's owner, so a producer cannot start work that owner cannot collect * or stop. One registry serves every composition in the process, so this - * question — and completion-listener delivery — is owner-relative rather - * than process-wide: registrations made from an unscoped context serve - * every owner, and registrations made under an agent composition's scope - * serve exactly the agents composed under it. - * - Record observation never consumes. Any number of {@link readRecord} - * readers hold their own absolute byte offsets; a record read changes no - * cursor and no notice state, so the model-facing surfaces (the consuming - * {@link read}, completion notices) are unaffected. - * - Record retention is bounded. Appends past the live cap drop the oldest + * question — and event delivery under `{ owners: 'scope' }` — is + * owner-relative rather than process-wide: registrations made from an + * unscoped context serve every owner, and registrations made under an agent + * composition's scope serve exactly the agents composed under it. + * - Every job owns one output ring. Pull sources named by the spec are pumped + * by the registry and drained once more before settlement; pushed appends + * land whole. The model's consuming cursor and observers' absolute offsets + * read the same bytes and never disturb each other. + * - Ring retention is bounded. Appends past the live cap drop the oldest * retained bytes; a reader below the retained window gets a lossy read, - * never an error. Settlement — the producer outcome, a kill, or teardown — - * trims retention to the settled cap and ends the stream; the record has no - * separate lifecycle. + * never an error. Settlement trims retention to the settled cap and ends + * the stream; the ring has no separate lifecycle. */ export abstract class JobRegistry extends Service { constructor(ctx: Context) { @@ -94,125 +90,28 @@ export abstract class JobRegistry extends Service { super(ctx, 'jobs') } + /** Lifecycle and output events, filtered per subscription. */ + abstract readonly events: JobEvents + /** * Preflight access, validation, owner cleanup, and implementation-owned * admission before starting and atomically registering work. Any preflight * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. - * Settlement records the outcome, notifies listeners, and releases waiters. - * @param spec - job identity, owner, and synchronous starter. + * @param spec - job identity, owner, output sources, and synchronous starter. * @returns the registry-issued `-N` id. */ - abstract start(spec: JobStart): JobId + abstract start(spec: JobSpec): JobId /** - * List caller-owned and unowned jobs in registration order without exposing - * another session's labels. - * @param caller - reading agent; a non-agent caller sees only unowned jobs. - * @returns fresh snapshots. + * Bind the caller's identity once and return its operations. A caller sees + * its own jobs and every unowned job; a caller-less view sees unowned jobs + * only. The view resolves visibility on every call, so it stays valid as + * jobs come and go. + * @param caller - the reading session, or undefined for an anonymous caller. + * @returns the operations available to that caller. */ - abstract list(caller?: Agent): JobSnapshot[] - - /** - * Return a non-consuming snapshot without changing its read cursor or notice - * state. Throws for an unknown or foreign job. - * @param id - job to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ - abstract get(id: JobId, caller?: Agent): JobSnapshot - - /** - * Read the next stream delta, or the idempotent final output after settlement. - * A terminal read marks the job reported. Throws for an unknown or foreign - * job. - * @param id - job to read. - * @param caller - reading agent checked against the owner. - * @returns output text and the post-read snapshot. - */ - abstract read(id: JobId, caller?: Agent): JobRead - - /** - * Request cancellation, then mark the job stopping and reported. A producer - * throw propagates without changing job state. Throws for an unknown or - * foreign job. - * @param id - job to cancel. - * @param caller - killing agent checked against the owner. - * @param reason - logged reason forwarded to the producer. - * @returns `requested` for live work, otherwise `already-finished`. - */ - abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' - - /** - * Wait for settlement or timeout without cancelling the job. Caller abort - * rejects only while the job is live; after settlement the terminal - * snapshot wins so a notice suppressed for this waiter is still delivered. - * Throws for invalid, unknown, or foreign input. - * @param id - job to wait for. - * @param timeoutMs - positive finite wait bound in milliseconds. - * @param caller - waiting agent checked against the owner. - * @param signal - optional cancellation of the wait itself. - * @returns snapshot at settlement or timeout. - */ - abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise - - /** - * Register an effect-scoped completion listener. It receives the settlements - * of the owners its registering context's scope covers; each listener is - * contained; returned promises are observed but not awaited. No listener runs - * after service disposal. - * @param listener - receives each terminal snapshot and its exact owner. - * @returns disposer that unregisters the listener. - */ - abstract onJobDone(listener: JobDoneListener): () => void - - /** - * Register an effect-scoped observer of visible-set changes. It fires after - * every commit that changes what {@link list} returns for that owner — - * registration, every stopping transition (including the one teardown - * performs before it awaits a slow producer), settlement, owner-disposal - * removal, and the emptying that service disposal commits — so an observer - * re-reads rather than accumulating deltas. - * - * Delivery is owner-relative on the same terms as {@link onJobDone}: an - * observer registered from an unscoped context — a host composition's own - * carrier — sees every owner, while one registered under an agent - * composition's scope sees exactly the agents composed under it. - * - * This is not a superset of {@link onJobDone}: that one delivers the terminal - * record under first-wins semantics a job controller couples to notice - * delivery, while this one carries no delivery meaning and marks nothing - * reported. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned job changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ - abstract onJobsChanged(listener: JobsChangedListener): () => void - - /** - * Read retained record output from an absolute byte offset without consuming - * it. Resume by passing a previous read's `next`; a foreign offset inside a - * retained chunk returns that whole chunk (its `at` may precede `from`). - * Never marks the job reported. Throws for an unknown or foreign job, a job - * without a {@link JobStart.record} declaration, or a negative or - * non-integer offset. - * @param id - job to read. - * @param from - absolute byte offset to read from (0 for the retained head). - * @param caller - reading agent checked against the owner. - * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. - */ - abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead - - /** - * Register an effect-scoped observer of record advancement — one signal per - * committed append and one at settlement, carrying only the job id. A - * consumer schedules a {@link readRecord} from its own cursor; the registry - * never pushes payloads. Delivery is owner-relative on the same terms as - * {@link onJobsChanged}. Listeners are contained and never awaited. - * @param listener - receives the id whose record advanced. - * @returns disposer that unregisters the listener. - */ - abstract onOutput(listener: JobOutputListener): () => void + abstract visibleTo(caller?: SessionId): VisibleJobs /** * Attach an effect-scoped controller that can read and stop jobs. It serves the diff --git a/packages/jobs/jobs/src/invariant.ts b/packages/jobs/jobs/src/invariant.ts index 36aca11fd8..84e3a22fe2 100644 --- a/packages/jobs/jobs/src/invariant.ts +++ b/packages/jobs/jobs/src/invariant.ts @@ -1,9 +1,8 @@ -/** Package-owned background-job snapshot invariants. @module @deepseek-ai/dsh-jobs/invariant */ +/** Package-owned background-job projection invariants. @module @deepseek-ai/dsh-jobs/invariant */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import type { JobSnapshot } from './types.ts' +import type { JobView } from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-jobs' const TERMINAL_STATUSES = new Set(['completed', 'killed', 'failed']) @@ -13,48 +12,44 @@ export const name = 'jobs-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate the cross-field relationships in one registry snapshot. */ -function validateSnapshot(snapshot: JobSnapshot, owner: Agent | undefined, fail: InvariantFailure): void { - const id = String(snapshot.id) - const prefix = `${snapshot.kind}-` +/** Validate the cross-field relationships in one registry projection. */ +function validateView(job: JobView, fail: InvariantFailure): void { + const id = String(job.id) + const prefix = `${job.kind}-` const ordinal = Number(id.slice(prefix.length)) - if (snapshot.kind.length === 0 || !id.startsWith(prefix) + if (job.kind.length === 0 || !id.startsWith(prefix) || !Number.isSafeInteger(ordinal) || ordinal < 1) { - fail(`job snapshot id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`) + fail(`job view id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`) } - if (snapshot.label.length === 0) fail(`job ${JSON.stringify(id)} label must be non-empty`) - if (!Number.isSafeInteger(snapshot.startedAt) || snapshot.startedAt < 0) { + if (job.label.length === 0) fail(`job ${JSON.stringify(id)} label must be non-empty`) + if (!Number.isSafeInteger(job.startedAt) || job.startedAt < 0) { fail(`job ${JSON.stringify(id)} startedAt must be a non-negative epoch integer`) } - const terminal = TERMINAL_STATUSES.has(snapshot.status) - if (terminal !== (snapshot.finishedAt !== undefined)) { + const terminal = TERMINAL_STATUSES.has(job.status) + if (terminal !== (job.finishedAt !== undefined)) { fail(`job ${JSON.stringify(id)} finishedAt must be present exactly for a terminal status`) } - if (snapshot.finishedAt !== undefined - && (!Number.isSafeInteger(snapshot.finishedAt) || snapshot.finishedAt < snapshot.startedAt)) { + if (job.finishedAt !== undefined + && (!Number.isSafeInteger(job.finishedAt) || job.finishedAt < job.startedAt)) { fail(`job ${JSON.stringify(id)} finishedAt must be an epoch integer no earlier than startedAt`) } - - const expectedOwner = owner?.id - if (snapshot.ownerSession !== expectedOwner) { - fail(`job ${JSON.stringify(id)} ownerSession does not match its completion owner`) + if (terminal && job.progress !== undefined) { + fail(`job ${JSON.stringify(id)} progress must be cleared once settled`) } - const { outputTotal, outputEarliest } = snapshot - if ((outputTotal !== undefined) !== (outputEarliest !== undefined)) { - fail(`job ${JSON.stringify(id)} record offsets must be present together`) - } else if (outputTotal !== undefined && outputEarliest !== undefined - && (!Number.isSafeInteger(outputTotal) || !Number.isSafeInteger(outputEarliest) - || outputEarliest < 0 || outputEarliest > outputTotal)) { - fail(`job ${JSON.stringify(id)} record offsets must satisfy 0 <= outputEarliest <= outputTotal`) + const { total, earliest } = job.output + if (!Number.isSafeInteger(total) || !Number.isSafeInteger(earliest) || earliest < 0 || earliest > total) { + fail(`job ${JSON.stringify(id)} output offsets must satisfy 0 <= earliest <= total`) } } -/** Install checks over current unowned records and every terminal snapshot. */ +/** Install checks over the current unowned projections and every settlement. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - for (const snapshot of ctx.jobs.list()) validateSnapshot(snapshot, undefined, fail) - ctx.jobs.onJobDone((snapshot, owner) => { validateSnapshot(snapshot, owner, fail) }) + for (const job of ctx.jobs.visibleTo().list()) validateView(job, fail) + ctx.jobs.events.subscribe({ owners: 'all' }, (event) => { + if (event.type === 'settled') validateView(event.job, fail) + }) }, { inject: ['jobs'] }) /** diff --git a/packages/jobs/jobs/src/pump.ts b/packages/jobs/jobs/src/pump.ts deleted file mode 100644 index a3c1956019..0000000000 --- a/packages/jobs/jobs/src/pump.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Shared poll pump for producers whose output substrate is a non-consuming - * offset reader (the subprocess `readFrom(fromByte)` family). Pure utility — - * no cordis, no timers retained past settlement. - * @module - */ - -import type { JobChannel, RecordingJob } from './types.ts' - -/** One non-consuming offset read from a producer-owned stream. */ -export interface JobPumpRead { - /** Text captured since the requested offset (the whole retained tail when lossy). */ - text: string - /** Whole-stream offset to resume from on the next read. */ - nextOffset: number - /** True when the requested offset slid out of the source's retained window. */ - lossy: boolean -} - -/** - * One producer-owned stream the pump copies into a job record. - * - * The source is a pull-by-offset snapshot reader, not a Node.js `Readable`, - * because the substrate it reads (the subprocess `readFrom` family) is a - * retained ring that several readers share: the model-facing consuming - * cursor, the foreground result collector, and this record pump each hold - * their own absolute offset and read without disturbing the others. A - * `Readable` is single-consumer and consuming, so wrapping the ring in one - * would need a tee per reader and could not express `lossy` — the reader's - * offset having slid out of the retained window — which the record turns - * into a visible `gapBefore` instead of a silent splice. - */ -export interface JobPumpSource { - /** Stream label attached to every chunk this source yields. */ - channel?: JobChannel - /** - * Read everything captured since `fromByte` without consuming it. - * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). - * @returns the delta text, the next offset, and the lossy flag. - */ - read(fromByte: number): JobPumpRead -} - -/** Cadence and settlement for one {@link pumpJobOutput} run. */ -export interface JobPumpOptions { - /** Poll interval in milliseconds; a positive finite number. */ - pollMs: number - /** - * Settles when the producer's work finished and its sources hold their - * final bytes. The pump drains once more after settlement and returns; - * a rejection is treated as settlement. - */ - done: Promise -} - -/** - * Copy producer streams into a job record at a bounded cadence: drain every - * source, sleep `pollMs` or until `done` settles, repeat, then drain one - * final time. A lossy source read appends its surviving tail with - * `gapBefore`, so the discontinuity stays visible to observers. Fold the - * returned promise into the producer's `JobHooks.done` chain so the final - * drain lands before settlement trims and closes the record. - * - * Each round drains the sources in array order, so bytes two sources - * produced inside one poll window land in that order, not in the order they - * were written; the record is a best-effort live view whose cross-source - * reordering is bounded by `pollMs`, not a terminal transcript. - * - * The wait holds constant resources however long the job runs: one - * subscription on `done` for the whole run and one pending timer at a time. - * @param job - the running job's producer face receiving the copied chunks. - * @param sources - producer streams, each pumped at its own offset. - * @param options - poll cadence and the producer's settlement promise. - * @returns resolves after the final post-settlement drain. - */ -export async function pumpJobOutput( - job: RecordingJob, - sources: readonly JobPumpSource[], - options: JobPumpOptions, -): Promise { - if (!Number.isFinite(options.pollMs) || options.pollMs <= 0) { - throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(options.pollMs)}`) - } - const states = sources.map(source => ({ source, cursor: 0 })) - const drain = (): void => { - for (const state of states) { - const { source } = state - const read = source.read(state.cursor) - state.cursor = read.nextOffset - if (read.text.length === 0) continue - if (source.channel === undefined && !read.lossy) { - job.append(read.text) - } else { - job.append(read.text, { - ...source.channel !== undefined ? { channel: source.channel } : {}, - ...read.lossy ? { gapBefore: true as const } : {}, - }) - } - } - } - let settled = false - let timer: ReturnType | undefined - let wake: (() => void) | undefined - const finish = (): void => { - settled = true - // Clear the pending poll so a finished pump holds no timer for up to one - // interval, and release the current wait so the final drain runs at once. - if (timer !== undefined) clearTimeout(timer) - wake?.() - } - void options.done.then(finish, finish) - while (!settled) { - drain() - await new Promise((resolve) => { - wake = resolve - timer = setTimeout(resolve, options.pollMs) - }) - wake = undefined - timer = undefined - } - drain() -} diff --git a/packages/jobs/jobs/src/types.ts b/packages/jobs/jobs/src/types.ts index 3ec3f87357..0f46ff1715 100644 --- a/packages/jobs/jobs/src/types.ts +++ b/packages/jobs/jobs/src/types.ts @@ -1,154 +1,98 @@ /** - * Types shared by job producers, the registry, and controllers. The - * service implementation lives in `./index.ts`. + * Types shared by job producers, the registry, and its consumers. The + * client-safe projection vocabulary lives in `./view.ts`; the service + * implementation lives in `./index.ts`. * @module @deepseek-ai/dsh-jobs/types */ -import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import type { JobId } from './brand.ts' +import type { JobChannel, JobChunk, JobKind, JobView } from './view.ts' export { JobId } from './brand.ts' - -/** - * Task lifecycle: `running`, optionally `stopping`, then exactly one terminal - * status. Producer-specific facts belong in {@link JobSnapshot.detail}. - */ -export type JobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' - -/** - * Producer-defined job kinds. Plugins extend this map by declaration merging; - * the registry treats every value as an opaque id namespace. - */ -export interface JobKindMap { - bash: 'bash' - subagent: 'subagent' -} - -/** The merge-extensible union of registered producer kind names. */ -export type JobKind = JobKindMap[keyof JobKindMap] +export type { JobChannel, JobChunk, JobKind, JobKindMap, JobStatus, JobView } from './view.ts' /** Terminal result supplied by a producer through {@link JobHooks.done}. */ export interface JobOutcome { /** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' - /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ + /** + * Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`). + * When the job settles `killed` after a {@link VisibleJobs.kill} with a + * reason, the registry appends that reason. + */ detail?: string - /** Final output for jobs without `readOutput`; stream jobs leave it unset. */ - output?: string + /** + * Return value for jobs whose result is a value rather than a stream (a + * workflow's rendered result, a subagent's report). The output ring carries + * the stream; this is handed out once by the model's next {@link VisibleJobs.read}. + */ + result?: string +} + +/** One incremental read from a {@link JobOutputSource}. */ +export interface JobSourceRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean } /** - * Record-stream label. Producers without distinct streams omit it; consumers - * treat an unrecognized label like an absent one. + * A pull source the registry pumps into the job's output ring — the subprocess + * `readFrom` family. The registry owns the cadence and drains every source one + * last time before settlement closes the ring, so a producer folds nothing + * into its `done`. */ -export type JobChannel = 'stdout' | 'stderr' +export interface JobOutputSource { + /** Stream label attached to every chunk this source yields. */ + channel?: JobChannel + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, and the lossy flag. + */ + read(fromByte: number): JobSourceRead +} -/** Options for one {@link RunningJob.append}. */ +/** Options for one {@link JobHandle.append}. */ export interface JobAppendOptions { /** Stream label for the chunk; omitted for producers without distinct streams. */ channel?: JobChannel /** * Marks that producer-side bytes between the previous chunk and this one - * were lost (a source window slid before the producer could copy it), so an - * observer can render the discontinuity instead of a silent splice. + * were lost, so an observer can render the discontinuity instead of a + * silent splice. */ gapBefore?: true } -/** Fields shared by every producer declaration passed to {@link JobRegistry.start}. */ -interface JobStartBase { - /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ - kind: JobKind - /** One-line model-facing label (the command; the delegation description). */ - label: string - /** - * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. Independent of record - * retention: it bounds the consuming model surface, never - * {@link JobRegistry.readRecord}. - */ - outputLimitBytes?: number - /** - * Owning live agent. Access is fenced by its session id, and agent disposal - * cancels and awaits the job. The instance must be the one currently - * registered under its agent id. Omitting the owner creates an unowned job, - * open to any caller until service disposal. - */ - owner?: Agent -} - /** - * A start with the model-facing surfaces only. Its producer face carries no - * `append`, so output the registry would have nowhere to keep is - * unrepresentable rather than dropped. + * Producer face of one registered job, handed to {@link JobSpec.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — writes log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. */ -export interface PlainJobStart extends JobStartBase { - record?: undefined - /** - * Start the work after preflight and synchronously return its hooks. Called - * once with the job's producer face; a throw leaves nothing registered (the - * spent ordinal is skipped), and the producer must clean up any partially - * started resources. - * @param job - the issued id plus the live-detail writer. - */ - run(job: RunningJob): JobHooks -} - -/** - * A start that declares an observable output record beside the model-facing - * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that - * any number of observers read at absolute byte offsets through - * {@link JobRegistry.readRecord}, and snapshots carry - * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. - */ -export interface RecordingJobStart extends JobStartBase { - record: true - /** - * Start the work after preflight and synchronously return its hooks. Called - * once with the job's producer face; a throw leaves nothing registered (the - * spent ordinal is skipped), and the producer must clean up any partially - * started resources. - * @param job - the issued id plus the record append and live-detail writers. - */ - run(job: RecordingJob): JobHooks -} - -/** - * Producer declaration passed to {@link JobRegistry.start}, discriminated by - * `record`. The runtime preflights access and cleanup before invoking `run`; - * the producer owns execution resources while the runtime owns identity and - * lifecycle state. - */ -export type JobStart = PlainJobStart | RecordingJobStart - -/** - * Producer face of one registered job, handed to `run` and valid for the - * job's whole life. All methods are synchronous. Writes staged inside the - * starter call are retained and become visible with the registration commit; - * after settlement — the producer's own outcome, a kill, or a registry-forced - * teardown end — writes log and drop instead of throwing, so a producer's - * trailing flush cannot break its own teardown path. - */ -export interface RunningJob { +export interface JobHandle { /** The registry-issued id (`-N`). */ readonly id: JobId /** - * Replace the snapshot's status detail with a live progress line (`3/10`). - * @param detail - the new detail line. - */ - updateDetail(detail: string): void -} - -/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */ -export interface RecordingJob extends RunningJob { - /** - * Append one record chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. + * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 + * byte length; an empty chunk is dropped without waking observers. * @param text - the chunk text, exactly as produced. * @param options - stream label and gap marker. */ append(text: string, options?: JobAppendOptions): void + /** + * Replace the live progress line (`3/10`, the current phase). Settlement + * clears it; the terminal reason travels in {@link JobOutcome.detail}. + * @param line - the new progress line. + */ + updateProgress(line: string): void } /** Hooks through which the runtime controls and observes producer work. */ @@ -162,104 +106,82 @@ export interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. Settlement also - * ends the record: fold any record-filling pump into this promise so the - * final drain lands before the registry trims and closes the stream. + * registry record without claiming that the work stopped. */ done: Promise - /** - * Consume output produced since the previous call. The producer formats - * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. Independent of the record: this is the - * model-facing projection, {@link JobRegistry.readRecord} the observer one. - */ - readOutput?(): string } /** - * A read-only projection of one job, safe to hand to listeners and tools — - * a fresh object per call, never live registry state. + * Producer declaration passed to {@link JobRegistry.start}. The runtime + * preflights access and cleanup before invoking {@link run}; the producer owns + * execution resources while the runtime owns identity, lifecycle state, and + * the output ring. */ -export interface JobSnapshot { - /** The registry-issued id (`-N`). */ - id: JobId - /** The producer kind the job was registered with. */ +export interface JobSpec { + /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ kind: JobKind - /** The producer-supplied one-line label. */ + /** One-line model-facing label (the command; the delegation description). */ label: string - /** Producer-owned cap for complete model-facing notices and output reads. */ + /** + * Owning session. Access is fenced by it, and the owner's live Agent must be + * the one currently registered under that id: its disposal cancels and + * awaits the job. Omitting the owner creates an unowned job, open to any + * caller until service disposal. + */ + owner?: SessionId + /** + * Optional UTF-8 byte cap for each complete model-facing completion notice or + * output read, including controller status metadata. Independent of ring + * retention: it bounds the consuming model surface, never observers. + */ outputLimitBytes?: number /** - * Owner session id used for authorization and correlation; absent for - * unowned jobs. Completion listeners receive the exact {@link Agent} - * separately through {@link JobDoneListener}. + * Pull sources the registry pumps into the ring at its own cadence. + * Producers that narrate their own progress use {@link JobHandle.append} + * instead; a job may use both. */ - ownerSession?: SessionId - /** Current lifecycle state. */ - status: JobStatus + output?: readonly JobOutputSource[] /** - * Kind-specific status detail: live progress while the producer updates it - * through {@link RunningJob.updateDetail}, the terminal detail once settled. + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the ring append and progress writers. */ - detail?: string - /** Epoch ms when the job was registered. */ - startedAt: number - /** Epoch ms when the job settled; absent while `running`/`stopping`. */ - finishedAt?: number - /** - * True when a kill, read, wait, or teardown cancel has reported or committed - * to report the terminal state. Completion reporters suppress redundant - * notices when set. Teardown claims it because the owner or service being - * destroyed leaves no reader: a reporter that opens a turn on notice would - * otherwise spend a model request per teardown layer. - */ - reported: boolean - /** - * Total UTF-8 bytes ever appended to the record — the offset the next chunk - * starts at. Present exactly when the job declared {@link JobStart.record}. - */ - outputTotal?: number - /** - * Offset of the oldest retained record byte. Greater than zero exactly when - * retention dropped the head; a reader starting below it gets a lossy read. - * Present exactly when the job declared {@link JobStart.record}. - */ - outputEarliest?: number + run(job: JobHandle): JobHooks } -/** Output and post-read state returned by {@link JobRegistry.read}. */ +/** Options for {@link VisibleJobs.kill}. */ +export interface JobKillOptions { + /** + * Cancellation reason: forwarded verbatim to the producer's cancel hook, and + * merged into the terminal `detail` when the job settles `killed`, so both + * the model and observers can see why the work stopped. + */ + reason?: string +} + +/** Output and post-read state returned by the consuming {@link VisibleJobs.read}. */ export interface JobRead { - /** - * Stream kinds: the consuming delta since the previous read. Final-output - * kinds: empty while live, the terminal {@link JobOutcome.output} (or - * empty) once settled — idempotent, never consumed. - */ - text: string + /** Ring chunks appended since the model cursor, in offset order; every channel included. */ + chunks: readonly JobChunk[] + /** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean + /** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */ + result?: string /** The job's state at read time. */ - snapshot: JobSnapshot + job: JobView } -/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ -export interface JobRecordChunk { - /** Absolute offset of the chunk's first byte. */ - at: number - /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ - text: string - /** Stream label, when the producer supplied one. */ - channel?: JobChannel - /** Producer-reported loss immediately before this chunk. */ - gapBefore?: true -} - -/** Result of one non-consuming {@link JobRegistry.readRecord}. */ -export interface JobRecordRead { +/** Result of one non-consuming {@link VisibleJobs.readAt}. */ +export interface JobOutputRead { /** Retained chunks overlapping `[from, total)`, in offset order. */ - chunks: readonly JobRecordChunk[] + chunks: readonly JobChunk[] /** - * Offset to resume from — the record's current `outputTotal`. Always a - * chunk boundary: appends land whole and trimming only advances chunk - * starts, and consumers concatenate `chunks` under that assumption, so a - * provider serving partial chunks would silently duplicate text. + * Offset to resume from — the ring's current `total`. Always a chunk + * boundary: appends land whole and trimming only advances chunk starts, and + * consumers concatenate `chunks` under that assumption, so a provider + * serving partial chunks would silently duplicate text. */ next: number /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ @@ -267,28 +189,118 @@ export interface JobRecordRead { } /** - * Completion callback with the exact owner supplied at start, or `undefined` - * for an unowned job. Returned promises are observed but not awaited. + * The operations one caller may perform, bound by {@link JobRegistry.visibleTo}. + * A caller sees its own jobs and every unowned job; every method throws for + * an id outside that set, without distinguishing unknown from foreign. */ -export type JobDoneListener = ( - snapshot: JobSnapshot, - owner: Agent | undefined, -) => void | PromiseLike +export interface VisibleJobs { + /** + * List the visible jobs in registration order. + * @returns fresh projections. + */ + list(): JobView[] + /** + * Project one job without touching its cursor. + * @param id - job to look up. + * @returns a fresh projection. + */ + get(id: JobId): JobView + /** + * Consume the ring from the model cursor and advance it to the current + * total. After settlement the first read also carries the producer's + * result; later reads return only what was appended since. + * @param id - job to read. + * @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection. + */ + read(id: JobId): JobRead + /** + * Read retained ring output from an absolute byte offset without consuming + * it. Resume by passing a previous read's `next`; an offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Never moves the model cursor. Throws for a negative or non-integer offset. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ + readAt(id: JobId, from: number): JobOutputRead + /** + * Request cancellation, then mark the job stopping. A recorded + * {@link JobKillOptions.reason} merges into the terminal `detail` when the + * job settles `killed`. A producer throw propagates without changing job + * state. + * @param id - job to cancel. + * @param options - cancellation reason. + * @returns `requested` for live work, otherwise `already-finished`. + */ + kill(id: JobId, options?: JobKillOptions): 'requested' | 'already-finished' + /** + * Wait for settlement or timeout without cancelling the job. Caller abort + * rejects only while the job is live; after settlement the terminal + * projection wins. Throws for an invalid timeout. + * @param id - job to wait for. + * @param timeoutMs - positive finite wait bound in milliseconds. + * @param signal - optional cancellation of the wait itself. + * @returns projection at settlement or timeout. + */ + wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise +} /** - * Observation callback for a change to what one owner's {@link JobRegistry.list} - * would return. It is owner-granular rather than job-granular because the - * change may be a removal, which no per-job record can express, and because - * its consumers re-read the whole visible set anyway. - * - * An `undefined` owner means an unowned job changed, so every caller's visible - * set changed with it. + * Why a job settled: its producer finished (`producer`), a + * {@link VisibleJobs.kill} ran first (`kill`), or owner or service teardown + * cancelled it (`teardown`) — a settlement whose owner has no reader left. */ -export type JobsChangedListener = (owner: Agent | undefined) => void +export type JobSettleCause = 'producer' | 'kill' | 'teardown' /** - * Observer of record advancement for one job: new appended output, or the - * settlement that ends the stream. Carries no payload — a consumer schedules a - * {@link JobRegistry.readRecord} from its own cursor. + * One lifecycle or output event. Lifecycle events carry the job's projection + * after the commit they announce; `output` carries only the id and the new + * total, so an observer schedules a {@link VisibleJobs.readAt} from its own + * cursor and the registry never pushes payloads. */ -export type JobOutputListener = (id: JobId) => void +export type JobEvent = + | { + /** Registration commit, progress line change, stopping transition, or removal from the visible set. */ + readonly type: 'registered' | 'progress' | 'stopping' | 'removed' + readonly job: JobView + } + | { + readonly type: 'settled' + readonly job: JobView + readonly cause: JobSettleCause + } + | { + readonly type: 'output' + readonly id: JobId + /** Owning session, absent for an unowned job. */ + readonly owner?: SessionId + /** The ring's total after the append (or at settlement, which ends the stream). */ + readonly total: number + } + +/** + * Who a subscription hears about. `{ owner }` delivers that session's jobs + * plus every unowned job (the set that session can see). `{ owners: 'scope' }` + * delivers the owners composed under the subscribing context — one registry + * serves every composition in the process, and a mount under one preset must + * not hear another preset's agents. `{ owners: 'all' }` delivers everything. + */ +export type JobEventFilter = + | { readonly owner: SessionId } + | { readonly owners: 'all' | 'scope' } + +/** Event callback; contained by the registry, never awaited. */ +export type JobEventListener = (event: JobEvent) => void + +/** The registry's event stream. */ +export interface JobEvents { + /** + * Register an effect-scoped listener. Events are dispatched synchronously + * after the commit they announce; a `settled` event follows the release of + * every waiter. No listener runs after service disposal. + * @param filter - which owners' events to deliver. + * @param listener - receives each matching event. + * @returns disposer that unregisters the listener. + */ + subscribe(filter: JobEventFilter, listener: JobEventListener): () => void +} diff --git a/packages/jobs/jobs/src/view.ts b/packages/jobs/jobs/src/view.ts new file mode 100644 index 0000000000..033fcfbe76 --- /dev/null +++ b/packages/jobs/jobs/src/view.ts @@ -0,0 +1,81 @@ +/** + * Client-safe job vocabulary: the read-only projection of one job and the + * chunks its output ring hands out. This leaf reaches no Host package, so + * browser programs and Remote wire types import it without pulling the + * registry's Host declaration merges. + * @module @deepseek-ai/dsh-jobs/view + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { JobId } from './brand.ts' + +/** + * Job lifecycle: `running`, optionally `stopping`, then exactly one terminal + * status. Producer-specific facts belong in {@link JobView.progress} and + * {@link JobView.detail}. + */ +export type JobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + +/** + * Producer-defined job kinds. Plugins extend this map by declaration merging; + * the registry treats every value as an opaque id namespace. + */ +export interface JobKindMap { + bash: 'bash' + subagent: 'subagent' +} + +/** The merge-extensible union of registered producer kind names. */ +export type JobKind = JobKindMap[keyof JobKindMap] + +/** + * Stream label of one output chunk. `stdout` and `stderr` reach the model's + * consuming read; `log` marks producer narration (a workflow's phase lines) + * that only observers see. Consumers treat an unrecognized label like an + * absent one. + */ +export type JobChannel = 'stdout' | 'stderr' | 'log' + +/** One chunk of a job's output ring: absolute offset, text, and its provenance. */ +export interface JobChunk { + /** Absolute offset of the chunk's first byte; offsets never move once assigned. */ + readonly at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + readonly text: string + /** Stream label, when the producer supplied one. */ + readonly channel?: JobChannel + /** Bytes immediately before this chunk were lost, at the producer or to retention. */ + readonly gapBefore?: true +} + +/** + * Read-only projection of one job — a fresh object per call, never live + * registry state. The model tools, the browser roster, and the observation + * stream all consume this one shape. + */ +export interface JobView { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** The producer kind the job was registered with. */ + readonly kind: JobKind + /** The producer-supplied one-line label. */ + readonly label: string + /** Owning session; absent for an unowned job, which every caller can see. */ + readonly owner?: SessionId + /** Current lifecycle state. */ + readonly status: JobStatus + /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */ + readonly progress?: string + /** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */ + readonly detail?: string + /** Epoch ms when the job was registered. */ + readonly startedAt: number + /** Epoch ms when the job settled; absent while live. */ + readonly finishedAt?: number + /** + * The output ring's absolute coordinates. `total` is the offset the next + * chunk starts at (0 while nothing was written); `earliest` is the oldest + * retained byte, greater than zero exactly when retention dropped the head. + */ + readonly output: { readonly total: number; readonly earliest: number } +} diff --git a/packages/jobs/jobs/tests/invariant.spec.ts b/packages/jobs/jobs/tests/invariant.spec.ts index fa42eb6c74..12a6d48bee 100644 --- a/packages/jobs/jobs/tests/invariant.spec.ts +++ b/packages/jobs/jobs/tests/invariant.spec.ts @@ -1,48 +1,49 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import JobRegistry, { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobDoneListener, JobSnapshot } from '@deepseek-ai/dsh-jobs' +import type { JobEventListener, JobView } from '@deepseek-ai/dsh-jobs' import * as JobsInvariant from '@deepseek-ai/dsh-jobs/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' -const BASE: JobSnapshot = { +const BASE: JobView = { id: JobId('bash-1'), kind: 'bash', label: 'compile', status: 'completed', startedAt: 10, finishedAt: 20, - reported: false, + output: { total: 0, earliest: 0 }, } -const RUNNING: JobSnapshot = { +const RUNNING: JobView = { id: JobId('bash-1'), kind: 'bash', label: 'compile', status: 'running', startedAt: 10, - reported: false, + output: { total: 0, earliest: 0 }, } -const TERMINAL_WITHOUT_FINISH: JobSnapshot = { +const TERMINAL_WITHOUT_FINISH: JobView = { id: JobId('bash-1'), kind: 'bash', label: 'compile', status: 'completed', startedAt: 10, - reported: false, + output: { total: 0, earliest: 0 }, } -async function setup(seed: JobSnapshot[] = []): Promise<(snapshot: unknown, owner?: Agent) => void> { +async function setup(seed: JobView[] = []): Promise<(job: unknown) => void> { const ctx = new Context() - let listener: JobDoneListener | undefined + let listener: JobEventListener | undefined const probe = { - list: () => seed, - onJobDone(value: JobDoneListener) { - listener = value - return () => { listener = undefined } + visibleTo: () => ({ list: () => seed }), + events: { + subscribe(_filter: unknown, value: JobEventListener) { + listener = value + return () => { listener = undefined } + }, }, } as unknown as JobRegistry await ctx.plugin(InvariantRegistry) @@ -51,44 +52,54 @@ async function setup(seed: JobSnapshot[] = []): Promise<(snapshot: unknown, owne apply(child: Context) { child.provide('jobs', probe) }, }) await ctx.plugin(JobsInvariant) - if (listener === undefined) throw new Error('job invariant did not subscribe to terminal snapshots') - return (snapshot, owner) => { listener!(snapshot as JobSnapshot, owner) } + if (listener === undefined) throw new Error('job invariant did not subscribe to settlements') + return (job) => { listener!({ type: 'settled', job: job as JobView, cause: 'producer' }) } } describe('job-registry invariants', () => { - it('accepts coherent current and terminal snapshots', async () => { + it('accepts coherent current and terminal projections', async () => { const notify = await setup([RUNNING]) expect(() => { notify(BASE) }).not.toThrow() - const owner = { id: SessionId('owner') } as Agent - expect(() => { notify({ ...BASE, id: JobId('subagent-2'), kind: 'subagent', ownerSession: owner.id }, owner) }) + expect(() => { notify({ ...BASE, id: JobId('subagent-2'), kind: 'subagent', owner: SessionId('owner') }) }) .not.toThrow() - expect(() => { notify({ ...BASE, outputTotal: 8, outputEarliest: 4 }) }).not.toThrow() + expect(() => { notify({ ...BASE, output: { total: 8, earliest: 4 } }) }).not.toThrow() + }) + + it('ignores non-settlement events', async () => { + const ctx = new Context() + let listener: JobEventListener | undefined + const probe = { + visibleTo: () => ({ list: () => [] }), + events: { subscribe(_filter: unknown, value: JobEventListener) { listener = value; return () => {} } }, + } as unknown as JobRegistry + await ctx.plugin(InvariantRegistry) + await ctx.plugin({ name: 'job-invariant-probe', apply(child: Context) { child.provide('jobs', probe) } }) + await ctx.plugin(JobsInvariant) + expect(() => { listener!({ type: 'registered', job: { ...BASE, label: '' } }) }).not.toThrow() }) it.each([ - [{ ...BASE, id: JobId('-1'), kind: '' }, undefined, /positive ordinal/], - [{ ...BASE, id: JobId('other-1') }, undefined, /must be "bash-" followed by a positive ordinal/], - [{ ...BASE, id: JobId('bash-x') }, undefined, /positive ordinal/], - [{ ...BASE, id: JobId('bash-0') }, undefined, /positive ordinal/], - [{ ...BASE, startedAt: -1 }, undefined, /startedAt must be a non-negative epoch integer/], - [{ ...BASE, startedAt: 0.5 }, undefined, /startedAt must be a non-negative epoch integer/], - [{ ...BASE, status: 'running' }, undefined, /finishedAt must be present exactly for a terminal status/], - [TERMINAL_WITHOUT_FINISH, undefined, /finishedAt must be present exactly for a terminal status/], - [{ ...BASE, finishedAt: 9 }, undefined, /no earlier than startedAt/], - [{ ...BASE, finishedAt: 20.5 }, undefined, /no earlier than startedAt/], - [{ ...BASE, ownerSession: SessionId('recorded') }, { id: SessionId('actual') } as Agent, /does not match its completion owner/], - [{ ...BASE, outputTotal: 8 }, undefined, /record offsets must be present together/], - [{ ...BASE, outputEarliest: 0 }, undefined, /record offsets must be present together/], - [{ ...BASE, outputTotal: 8, outputEarliest: 9 }, undefined, /0 <= outputEarliest <= outputTotal/], - [{ ...BASE, outputTotal: 8.5, outputEarliest: 0 }, undefined, /0 <= outputEarliest <= outputTotal/], - [{ ...BASE, outputTotal: 8, outputEarliest: 0.5 }, undefined, /0 <= outputEarliest <= outputTotal/], - [{ ...BASE, outputTotal: 8, outputEarliest: -1 }, undefined, /0 <= outputEarliest <= outputTotal/], - ] as const)('rejects an incoherent registry snapshot', async (snapshot, owner, message) => { + [{ ...BASE, id: JobId('-1'), kind: '' }, /positive ordinal/], + [{ ...BASE, id: JobId('other-1') }, /must be "bash-" followed by a positive ordinal/], + [{ ...BASE, id: JobId('bash-x') }, /positive ordinal/], + [{ ...BASE, id: JobId('bash-0') }, /positive ordinal/], + [{ ...BASE, startedAt: -1 }, /startedAt must be a non-negative epoch integer/], + [{ ...BASE, startedAt: 0.5 }, /startedAt must be a non-negative epoch integer/], + [{ ...BASE, status: 'running' }, /finishedAt must be present exactly for a terminal status/], + [TERMINAL_WITHOUT_FINISH, /finishedAt must be present exactly for a terminal status/], + [{ ...BASE, finishedAt: 9 }, /no earlier than startedAt/], + [{ ...BASE, finishedAt: 20.5 }, /no earlier than startedAt/], + [{ ...BASE, progress: '3/10' }, /progress must be cleared once settled/], + [{ ...BASE, output: { total: 8, earliest: 9 } }, /0 <= earliest <= total/], + [{ ...BASE, output: { total: 8.5, earliest: 0 } }, /0 <= earliest <= total/], + [{ ...BASE, output: { total: 8, earliest: 0.5 } }, /0 <= earliest <= total/], + [{ ...BASE, output: { total: 8, earliest: -1 } }, /0 <= earliest <= total/], + ] as const)('rejects an incoherent registry projection', async (job, message) => { const notify = await setup() - expect(() => { notify(snapshot, owner) }).toThrow(message) + expect(() => { notify(job) }).toThrow(message) }) - it('rejects an incoherent record already present at installation', async () => { + it('rejects an incoherent projection already present at installation', async () => { await expect(setup([{ ...BASE, label: '' }])).rejects.toThrow(/label must be non-empty/) }) }) diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index 331bf6596b..f3b456acbc 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -1,69 +1,60 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { JobId, JobRegistry, pumpJobOutput } from '@deepseek-ai/dsh-jobs' +import { SessionId } from '@deepseek-ai/dsh-session' +import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs' import type { - JobAppendOptions, JobDoneListener, JobHooks, JobOutputListener, JobPumpSource, JobRead, JobRecordRead, - JobSnapshot, JobStart, JobsChangedListener, PlainJobStart, RecordingJob, RecordingJobStart, + JobEvent, JobEventFilter, JobEventListener, JobHooks, JobOutputSource, JobRead, JobSpec, JobView, VisibleJobs, } from '@deepseek-ai/dsh-jobs' +/** Compile-time probe: does `Shape` carry a `Key` member? */ +type HasKey = Key extends keyof Shape ? true : false + /** - * Minimal concrete registry: one canned record. The Service Definition owns the contract - * only (ids, snapshots, authorization-shaped signatures); the registry + * Minimal concrete registry: one canned row. The Service Definition owns the + * contract only (ids, the caller-bound view, the event stream); the registry * behavior suite lives with `@deepseek-ai/dsh-jobs-local`. */ class StubJobRegistry extends JobRegistry { - snapshotOf(id: JobId): JobSnapshot { + readonly subscriptions: { filter: JobEventFilter; listener: JobEventListener }[] = [] + readonly started: JobSpec[] = [] + + readonly events = { + subscribe: (filter: JobEventFilter, listener: JobEventListener): (() => void) => { + const entry = { filter, listener } + this.subscriptions.push(entry) + return () => { this.subscriptions.splice(this.subscriptions.indexOf(entry), 1) } + }, + } + + private view(id: JobId, owner: SessionId | undefined): JobView { return { id, kind: 'bash', label: 'sleep 60', + ...owner !== undefined ? { owner } : {}, status: 'running', startedAt: 0, - reported: false, + output: { total: 0, earliest: 0 }, } } - start(spec: JobStart): JobId { + start(spec: JobSpec): JobId { + this.started.push(spec) const id = JobId(`${spec.kind}-1`) - spec.run({ id, append() {}, updateDetail() {} }) + spec.run({ id, append() {}, updateProgress() {} }) return id } - list(): JobSnapshot[] { - return [this.snapshotOf(JobId('bash-1'))] - } - - get(id: JobId): JobSnapshot { - return this.snapshotOf(id) - } - - read(id: JobId): JobRead { - return { text: '', snapshot: this.snapshotOf(id) } - } - - kill(): 'requested' | 'already-finished' { - return 'requested' - } - - wait(id: JobId, _timeoutMs: number, _caller?: Agent, _signal?: AbortSignal): Promise { - return Promise.resolve(this.snapshotOf(id)) - } - - onJobDone(_listener: JobDoneListener): () => void { - return () => {} - } - - onJobsChanged(_listener: JobsChangedListener): () => void { - return () => {} - } - - readRecord(_id: JobId, from: number): JobRecordRead { - return { chunks: [], next: from, lossy: false } - } - - onOutput(_listener: JobOutputListener): () => void { - return () => {} + visibleTo(caller?: SessionId): VisibleJobs { + const view = (id: JobId): JobView => this.view(id, caller) + return { + list: () => [view(JobId('bash-1'))], + get: id => view(id), + read: (id): JobRead => ({ chunks: [], lossy: false, job: view(id) }), + readAt: (_id, from) => ({ chunks: [], next: from, lossy: false }), + kill: () => 'requested', + wait: id => Promise.resolve(view(id)), + } } attachController(_name: string): () => void { @@ -71,63 +62,58 @@ class StubJobRegistry extends JobRegistry { } } -/** Compile-time probe: does the producer face `Face` carry `append`? */ -type HasAppend = 'append' extends keyof Face ? true : false - -describe('JobStart producer faces', () => { - it('hands append only to a start that declares a record', () => { - const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) } - // The literal's `record` discriminant selects the face `run` receives. - const plain: JobStart = { - kind: 'bash', - label: 'no record', - run: (job) => { - job.updateDetail('works for every job') - return hooks - }, - } - const recording: JobStart = { - kind: 'bash', - label: 'record', - record: true, - run: (job) => { - job.append('lands in the record') - return hooks - }, - } - // Negative proof: a plain start's face has no `append` at the type level. - const plainFace: HasAppend[0]> = false - const recordingFace: HasAppend[0]> = true - expect(plainFace).toBe(false) - expect(recordingFace).toBe(true) - expect(plain.record).toBeUndefined() - expect(recording.record).toBe(true) - }) -}) - describe('JobRegistry seam', () => { it('a concrete subclass registers as ctx.jobs and serves the abstract API', async () => { const ctx = new Context() await ctx.plugin(StubJobRegistry) + const caller = SessionId('session-1') + const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) } const detachController = ctx.jobs.attachController('seam-test') - const id = ctx.jobs.start({ kind: 'bash', label: 'sleep 60', run: () => ({ cancel() {}, done: new Promise(() => {}) }) }) + const id = ctx.jobs.start({ kind: 'bash', label: 'sleep 60', owner: caller, run: () => hooks }) expect(id).toBe('bash-1') - expect(ctx.jobs.list()).toHaveLength(1) - expect(ctx.jobs.get(id).status).toBe('running') - expect(ctx.jobs.read(id).text).toBe('') - expect(ctx.jobs.kill(id)).toBe('requested') - await expect(ctx.jobs.wait(id, 5)).resolves.toMatchObject({ id }) - expect(ctx.jobs.readRecord(id, 0)).toEqual({ chunks: [], next: 0, lossy: false }) - const detachListener = ctx.jobs.onJobDone(() => {}) - detachListener() - const detachChanges = ctx.jobs.onJobsChanged(() => {}) - detachChanges() - const detachOutput = ctx.jobs.onOutput(() => {}) - detachOutput() + + const jobs = ctx.jobs.visibleTo(caller) + expect(jobs.list()).toHaveLength(1) + expect(jobs.get(id).status).toBe('running') + expect(jobs.get(id).owner).toBe(caller) + expect(jobs.read(id)).toEqual({ chunks: [], lossy: false, job: expect.objectContaining({ id }) }) + expect(jobs.readAt(id, 7)).toEqual({ chunks: [], next: 7, lossy: false }) + expect(jobs.kill(id, { reason: 'seam test' })).toBe('requested') + await expect(jobs.wait(id, 5)).resolves.toMatchObject({ id }) + // A caller-less view is the unowned-only view. + expect(ctx.jobs.visibleTo().get(id).owner).toBeUndefined() + + const events: JobEvent[] = [] + const unsubscribe = ctx.jobs.events.subscribe({ owners: 'all' }, (event) => { events.push(event) }) + unsubscribe() detachController() }) + it('types every start the same way: one handle, pull sources optional', () => { + const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) } + const source: JobOutputSource = { + channel: 'stdout', + read: fromByte => ({ text: '', nextOffset: fromByte, lossy: false }), + } + const spec: JobSpec = { + kind: 'bash', + label: 'x', + output: [source], + run: (job) => { + job.append('lands in the ring', { channel: 'log' }) + job.updateProgress('1/2') + return hooks + }, + } + expect(spec.output).toHaveLength(1) + // Bookkeeping never reaches the public view, and the record declaration is gone. + const viewHasReported: HasKey = false + const specHasRecord: HasKey = false + const hooksHaveReadOutput: HasKey = false + expect([viewHasReported, specHasRecord, hooksHaveReadOutput]).toEqual([false, false, false]) + }) + it('loading a second implementation throws (one jobs service per context — cordis standard)', async () => { const ctx = new Context() await ctx.plugin(StubJobRegistry) @@ -141,119 +127,3 @@ describe('JobRegistry seam', () => { .rejects.toThrow(/abstract job registry seam; load an implementation such as @deepseek-ai\/dsh-jobs-local/) }) }) - -/** Record every append so pump behavior is observable without a registry. */ -function recordingJob(): { job: RecordingJob; appends: { text: string; options?: JobAppendOptions }[] } { - const appends: { text: string; options?: JobAppendOptions }[] = [] - const job: RecordingJob = { - id: JobId('bash-1'), - append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) }, - updateDetail() {}, - } - return { job, appends } -} - -/** A scripted source: each read() shifts the next scripted result. */ -function scriptedSource( - reads: { text: string; lossy?: boolean }[], - channel?: 'stdout' | 'stderr', -): { source: JobPumpSource; offsets: number[] } { - const offsets: number[] = [] - let offset = 0 - const source: JobPumpSource = { - ...channel !== undefined ? { channel } : {}, - read(fromByte) { - offsets.push(fromByte) - const next = reads.shift() ?? { text: '' } - offset += Buffer.byteLength(next.text, 'utf8') - return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } - }, - } - return { source, offsets } -} - -describe('pumpJobOutput', () => { - it('rejects a non-positive poll interval before touching any source', async () => { - const { job } = recordingJob() - await expect(pumpJobOutput(job, [], { pollMs: 0, done: Promise.resolve() })) - .rejects.toThrow(/invalid pump pollMs/) - }) - - it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => { - vi.useFakeTimers() - try { - const { job, appends } = recordingJob() - let settle!: () => void - const done = new Promise((resolve) => { settle = resolve }) - const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') - const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr') - const pump = pumpJobOutput(job, [out.source, err.source], { pollMs: 50, done }) - - await vi.advanceTimersByTimeAsync(50) - settle() - await pump - expect(appends).toEqual([ - { text: 'a', options: { channel: 'stdout' } }, - { text: 'bc', options: { channel: 'stdout' } }, - { text: 'E', options: { channel: 'stderr' } }, - ]) - // Every read resumed from the previous nextOffset, never from a shared cursor. - expect(out.offsets).toEqual([0, 1, 3]) - expect(err.offsets).toEqual([0, 0, 1]) - // Settlement cleared the raced poll timer instead of leaving it pending. - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('subscribes to done once and holds one poll timer however long the job runs', async () => { - vi.useFakeTimers() - try { - const { job } = recordingJob() - // A thenable stands in for the producer's done promise, and whatever the - // pump derives from it counts its own reactions too: a pending promise - // retains every reaction until it settles, so a day-long job must not - // gain one per poll round. - let subscriptions = 0 - let settle!: () => void - const settled = new Promise((resolve) => { settle = resolve }) - const counting = (promise: Promise): Promise => ({ - then(onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) { - subscriptions += 1 - return counting(promise.then(onFulfilled, onRejected)) - }, - }) as unknown as Promise - const done = counting(settled) - const { source, offsets } = scriptedSource([]) - const pump = pumpJobOutput(job, [source], { pollMs: 50, done }) - - const rounds = 10_000 - await vi.advanceTimersByTimeAsync(50 * rounds) - expect(offsets).toHaveLength(rounds + 1) - expect(subscriptions).toBeLessThanOrEqual(2) - expect(vi.getTimerCount()).toBe(1) - - settle() - await pump - expect(offsets).toHaveLength(rounds + 2) - expect(vi.getTimerCount()).toBe(0) - } finally { - vi.useRealTimers() - } - }) - - it('marks a lossy source read as a gap so observers see the discontinuity', async () => { - const { job, appends } = recordingJob() - const { source } = scriptedSource([{ text: 'tail', lossy: true }]) - await pumpJobOutput(job, [source], { pollMs: 1, done: Promise.resolve() }) - expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }]) - }) - - it('treats a rejected done as settlement and still drains the final bytes', async () => { - const { job, appends } = recordingJob() - const { source } = scriptedSource([{ text: 'last' }]) - await pumpJobOutput(job, [source], { pollMs: 1, done: Promise.reject(new Error('producer broke')) }) - expect(appends).toEqual([{ text: 'last' }]) - }) -}) diff --git a/tsconfig.base.json b/tsconfig.base.json index bfdee3acfb..97ea6bcf0c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -102,6 +102,7 @@ "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"], + "@deepseek-ai/dsh-jobs/view": ["./packages/jobs/jobs/src/view.ts"], "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"], "@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"], "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"], From 1b97207150ca8dc36cf6814aba8edeaec4f9f7ca Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 15:53:59 +0800 Subject: [PATCH 028/141] refactor(jobs-local): extract the output ring and the registry-owned pump --- packages/jobs/jobs-local/src/pump.ts | 85 +++++++++++++++ packages/jobs/jobs-local/src/ring.ts | 113 ++++++++++++++++++++ packages/jobs/jobs-local/tests/pump.spec.ts | 111 +++++++++++++++++++ packages/jobs/jobs-local/tests/ring.spec.ts | 65 +++++++++++ 4 files changed, 374 insertions(+) create mode 100644 packages/jobs/jobs-local/src/pump.ts create mode 100644 packages/jobs/jobs-local/src/ring.ts create mode 100644 packages/jobs/jobs-local/tests/pump.spec.ts create mode 100644 packages/jobs/jobs-local/tests/ring.spec.ts diff --git a/packages/jobs/jobs-local/src/pump.ts b/packages/jobs/jobs-local/src/pump.ts new file mode 100644 index 0000000000..e57cb02a09 --- /dev/null +++ b/packages/jobs/jobs-local/src/pump.ts @@ -0,0 +1,85 @@ +/** + * The registry-owned pull pump: copies a job's {@link JobOutputSource}s into + * its ring at a bounded cadence and drains them once more after the + * producer settles, so the ring holds every byte before settlement trims and + * closes it. Pure utility — no cordis, no timers retained past settlement. + * @module @deepseek-ai/dsh-jobs-local/pump + */ + +import type { JobAppendOptions, JobOutputSource } from '@deepseek-ai/dsh-jobs' + +/** One pump run; `done` resolves after the final post-settlement drain. */ +export interface PumpHandle { + done: Promise +} + +/** + * Drain every source in array order, sleep `pollMs` or until `until` + * settles, repeat, then drain one final time. A lossy source read appends + * its surviving tail with `gapBefore`, so the discontinuity stays visible to + * observers. Sources drain in array order each round, so bytes two sources + * produced inside one poll window land in that order, not in the order they + * were written: the ring is a best-effort live view whose cross-source + * reordering is bounded by `pollMs`. + * + * The wait holds constant resources however long the job runs: one + * subscription on `until` for the whole run and one pending timer at a time. + * @param sources - producer streams, each pumped at its own offset. + * @param append - the ring append; receives each copied chunk. + * @param pollMs - poll interval in milliseconds; a positive finite number. + * @param until - settles (or rejects, which counts as settlement) when the producer finished. + * @returns the handle whose `done` resolves after the final drain. + */ +export function startPump( + sources: readonly JobOutputSource[], + append: (text: string, options?: JobAppendOptions) => void, + pollMs: number, + until: Promise, +): PumpHandle { + if (!Number.isFinite(pollMs) || pollMs <= 0) { + throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(pollMs)}`) + } + const states = sources.map(source => ({ source, cursor: 0 })) + const drain = (): void => { + for (const state of states) { + const { source } = state + const read = source.read(state.cursor) + state.cursor = read.nextOffset + if (read.text.length === 0) continue + if (source.channel === undefined && !read.lossy) { + append(read.text) + } else { + append(read.text, { + ...source.channel !== undefined ? { channel: source.channel } : {}, + ...read.lossy ? { gapBefore: true as const } : {}, + }) + } + } + } + const done = (async (): Promise => { + let settled = false + let timer: ReturnType | undefined + let wake: (() => void) | undefined + const finish = (): void => { + settled = true + // Clear the pending poll so a finished pump holds no timer for up to one + // interval, and release the current wait so the final drain runs at once. + // `finish` always runs from a microtask, after the loop parked on its + // first timer, so the timer is never absent here. + clearTimeout(timer) + wake?.() + } + void until.then(finish, finish) + while (!settled) { + drain() + await new Promise((resolve) => { + wake = resolve + timer = setTimeout(resolve, pollMs) + }) + wake = undefined + timer = undefined + } + drain() + })() + return { done } +} diff --git a/packages/jobs/jobs-local/src/ring.ts b/packages/jobs/jobs-local/src/ring.ts new file mode 100644 index 0000000000..fb4bcb3c5b --- /dev/null +++ b/packages/jobs/jobs-local/src/ring.ts @@ -0,0 +1,113 @@ +/** + * The bounded output ring behind one job: chunks at absolute byte offsets, + * head eviction that never moves an assigned offset, and non-consuming reads + * from any offset. + * @module @deepseek-ai/dsh-jobs-local/ring + */ + +import type { JobAppendOptions, JobChannel, JobOutputRead } from '@deepseek-ai/dsh-jobs' + +/** One retained ring entry; `bytes` caches the chunk's UTF-8 length. */ +interface RingChunk { + at: number + text: string + bytes: number + channel?: JobChannel + gapBefore?: true +} + +/** + * The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut + * advances past continuation bytes so the surviving text never starts inside + * a code point. + * @param text - the oversized chunk text. + * @param maxBytes - positive byte budget for the surviving tail. + * @returns the surviving tail and its exact byte length. + */ +function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } { + const raw = Buffer.from(text, 'utf8') + let start = raw.length - maxBytes + // The loop bound proves the index is in range; the assertion only + // discharges noUncheckedIndexedAccess. + while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1 + const tail = raw.subarray(start) + return { text: tail.toString('utf8'), bytes: tail.length } +} + +/** Offsets stay absolute across eviction: `earliest` only ever advances. */ +export class OutputRing { + /** Retained chunks in offset order. */ + private readonly chunks: RingChunk[] = [] + /** Sum of the retained chunks' byte lengths. */ + retainedBytes = 0 + /** Total UTF-8 bytes ever appended — the offset the next chunk starts at. */ + total = 0 + /** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */ + earliest = 0 + + /** + * Append one chunk and trim the head to `cap`. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + * @param cap - retention cap in UTF-8 bytes after this append. + * @returns false when the chunk was empty and nothing changed. + */ + append(text: string, options: JobAppendOptions | undefined, cap: number): boolean { + if (text.length === 0) return false + const bytes = Buffer.byteLength(text, 'utf8') + this.chunks.push({ + at: this.total, + text, + bytes, + ...options?.channel !== undefined ? { channel: options.channel } : {}, + ...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {}, + }) + this.total += bytes + this.retainedBytes += bytes + this.trim(cap) + return true + } + + /** + * Drop retained head chunks until the ring fits `cap`; a single oversized + * chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. + * @param cap - retention cap in UTF-8 bytes. + */ + trim(cap: number): void { + while (this.retainedBytes > cap && this.chunks.length > 1) { + const dropped = this.chunks.shift() + /* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */ + if (dropped === undefined) break + this.retainedBytes -= dropped.bytes + } + const single = this.chunks.length === 1 ? this.chunks[0] : undefined + if (single !== undefined && single.bytes > cap) { + const tail = utf8Tail(single.text, cap) + single.at += single.bytes - tail.bytes + single.text = tail.text + single.bytes = tail.bytes + single.gapBefore = true + this.retainedBytes = tail.bytes + } + this.earliest = this.chunks[0]?.at ?? this.total + } + + /** + * Retained chunks overlapping `[from, total)` as fresh wire chunks. + * @param from - absolute byte offset to read from. + * @returns the chunks, the resume offset, and whether bytes before them were evicted. + */ + readFrom(from: number): JobOutputRead { + const chunks = [] + for (const chunk of this.chunks) { + if (chunk.at + chunk.bytes <= from) continue + chunks.push({ + at: chunk.at, + text: chunk.text, + ...chunk.channel !== undefined ? { channel: chunk.channel } : {}, + ...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {}, + }) + } + return { chunks, next: this.total, lossy: from < this.earliest } + } +} diff --git a/packages/jobs/jobs-local/tests/pump.spec.ts b/packages/jobs/jobs-local/tests/pump.spec.ts new file mode 100644 index 0000000000..31ee5d4d3f --- /dev/null +++ b/packages/jobs/jobs-local/tests/pump.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' +import type { JobAppendOptions, JobOutputSource } from '@deepseek-ai/dsh-jobs' +import { startPump } from '../src/pump.ts' + +/** Record every append so pump behavior is observable without a registry. */ +function sink() { + const appends: { text: string; options?: JobAppendOptions }[] = [] + return { + appends, + append: (text: string, options?: JobAppendOptions) => { appends.push({ text, ...options !== undefined ? { options } : {} }) }, + } +} + +/** A scripted source: each read() shifts the next scripted result. */ +function scriptedSource( + reads: { text: string; lossy?: boolean }[], + channel?: 'stdout' | 'stderr', +): { source: JobOutputSource; offsets: number[] } { + const offsets: number[] = [] + let offset = 0 + const source: JobOutputSource = { + ...channel !== undefined ? { channel } : {}, + read(fromByte) { + offsets.push(fromByte) + const next = reads.shift() ?? { text: '' } + offset += Buffer.byteLength(next.text, 'utf8') + return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } + }, + } + return { source, offsets } +} + +describe('startPump', () => { + it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => { + vi.useFakeTimers() + try { + const { appends, append } = sink() + let settle!: () => void + const until = new Promise((resolve) => { settle = resolve }) + const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') + const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr') + const pump = startPump([out.source, err.source], append, 50, until) + + await vi.advanceTimersByTimeAsync(50) + settle() + await pump.done + expect(appends).toEqual([ + { text: 'a', options: { channel: 'stdout' } }, + { text: 'bc', options: { channel: 'stdout' } }, + { text: 'E', options: { channel: 'stderr' } }, + ]) + // Every read resumed from the previous nextOffset, never from a shared cursor. + expect(out.offsets).toEqual([0, 1, 3]) + expect(err.offsets).toEqual([0, 0, 1]) + // Settlement cleared the pending poll timer instead of leaving it pending. + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('marks a lossy source read as a gap so observers see the discontinuity', async () => { + const { appends, append } = sink() + const { source } = scriptedSource([{ text: 'tail', lossy: true }]) + await startPump([source], append, 1, Promise.resolve()).done + expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }]) + }) + + it('treats a rejected settlement as settlement and still drains the final bytes', async () => { + const { appends, append } = sink() + const { source } = scriptedSource([{ text: 'last' }]) + await startPump([source], append, 1, Promise.reject(new Error('producer broke'))).done + expect(appends).toEqual([{ text: 'last' }]) + }) + + it('subscribes to the settlement once and holds one poll timer however long the job runs', async () => { + vi.useFakeTimers() + try { + const { append } = sink() + let subscriptions = 0 + let settle!: () => void + const settled = new Promise((resolve) => { settle = resolve }) + const counting = (promise: Promise): Promise => ({ + then(onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) { + subscriptions += 1 + return counting(promise.then(onFulfilled, onRejected)) + }, + }) as unknown as Promise + const { source, offsets } = scriptedSource([]) + const pump = startPump([source], append, 50, counting(settled)) + + const rounds = 10_000 + await vi.advanceTimersByTimeAsync(50 * rounds) + expect(offsets).toHaveLength(rounds + 1) + expect(subscriptions).toBeLessThanOrEqual(2) + expect(vi.getTimerCount()).toBe(1) + + settle() + await pump.done + expect(offsets).toHaveLength(rounds + 2) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('rejects a non-positive poll interval before touching any source', () => { + const { append } = sink() + expect(() => startPump([], append, 0, Promise.resolve())).toThrow(/invalid pump pollMs/) + }) +}) diff --git a/packages/jobs/jobs-local/tests/ring.spec.ts b/packages/jobs/jobs-local/tests/ring.spec.ts new file mode 100644 index 0000000000..670e121d0b --- /dev/null +++ b/packages/jobs/jobs-local/tests/ring.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { OutputRing } from '../src/ring.ts' + +describe('OutputRing', () => { + it('assigns absolute offsets by UTF-8 byte length and drops empty chunks silently', () => { + const ring = new OutputRing() + expect(ring.append('', undefined, 1024)).toBe(false) + expect(ring.append('héllo', { channel: 'stdout' }, 1024)).toBe(true) + expect(ring.append('wörld', { channel: 'stderr', gapBefore: true }, 1024)).toBe(true) + expect(ring.total).toBe(12) + expect(ring.earliest).toBe(0) + expect(ring.retainedBytes).toBe(12) + expect(ring.readFrom(0)).toEqual({ + chunks: [ + { at: 0, text: 'héllo', channel: 'stdout' }, + { at: 6, text: 'wörld', channel: 'stderr', gapBefore: true }, + ], + next: 12, + lossy: false, + }) + }) + + it('reads from a foreign offset by returning the whole overlapping chunk', () => { + const ring = new OutputRing() + ring.append('abc', undefined, 1024) + ring.append('def', undefined, 1024) + expect(ring.readFrom(4).chunks).toEqual([{ at: 3, text: 'def' }]) + expect(ring.readFrom(6)).toEqual({ chunks: [], next: 6, lossy: false }) + }) + + it('trims whole head chunks past the cap and reports the evicted span as lossy', () => { + const ring = new OutputRing() + ring.append('aaaa', undefined, 8) + ring.append('bbbb', undefined, 8) + ring.append('cc', undefined, 8) + // 10 bytes retained > cap 8: the oldest whole chunk goes. + expect(ring.earliest).toBe(4) + expect(ring.retainedBytes).toBe(6) + expect(ring.readFrom(0)).toEqual({ chunks: [{ at: 4, text: 'bbbb' }, { at: 8, text: 'cc' }], next: 10, lossy: true }) + expect(ring.readFrom(4).lossy).toBe(false) + }) + + it('keeps a lone oversized chunk as a UTF-8-safe tail marked gapBefore', () => { + const ring = new OutputRing() + // 3 two-byte code points = 6 bytes; a 3-byte cap must not start inside a code point. + ring.append('ééé', undefined, 3) + expect(ring.retainedBytes).toBe(2) + expect(ring.earliest).toBe(4) + expect(ring.readFrom(0)).toEqual({ chunks: [{ at: 4, text: 'é', gapBefore: true }], next: 6, lossy: true }) + }) + + it('re-trims to a smaller cap on demand and leaves total untouched', () => { + const ring = new OutputRing() + ring.append('12345678', undefined, 64) + ring.append('abcdefgh', undefined, 64) + ring.trim(8) + expect(ring.total).toBe(16) + expect(ring.earliest).toBe(8) + expect(ring.readFrom(8)).toEqual({ chunks: [{ at: 8, text: 'abcdefgh' }], next: 16, lossy: false }) + // Trimming an empty ring is a no-op that keeps earliest at total. + const empty = new OutputRing() + empty.trim(1) + expect(empty.earliest).toBe(0) + }) +}) From f55e9805c753af225dc80fcc9b0d4159dc860d36 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 16:03:16 +0800 Subject: [PATCH 029/141] refactor(jobs-local): one ring, two cursors, and event routing Every job owns an `OutputRing`; the model's `read` consumes from a registry-kept cursor while `readAt` reads at absolute offsets. Pull sources named by the spec are pumped by the registry and drained once more before settlement. `visibleTo(caller)` binds the session fence once; the three listener families become one event hub with explicit filters; `reported` is gone and settlements announce their cause. --- packages/jobs/jobs-local/src/events.ts | 98 ++ packages/jobs/jobs-local/src/index.ts | 741 ++++++------- packages/jobs/jobs-local/tests/events.spec.ts | 138 +++ packages/jobs/jobs-local/tests/jobs.spec.ts | 983 ++++++++---------- 4 files changed, 967 insertions(+), 993 deletions(-) create mode 100644 packages/jobs/jobs-local/src/events.ts create mode 100644 packages/jobs/jobs-local/tests/events.spec.ts diff --git a/packages/jobs/jobs-local/src/events.ts b/packages/jobs/jobs-local/src/events.ts new file mode 100644 index 0000000000..2e39f7089f --- /dev/null +++ b/packages/jobs/jobs-local/src/events.ts @@ -0,0 +1,98 @@ +/** + * Event routing for the local registry: subscriptions file by filter, and + * every commit dispatches once to each matching listener with containment. + * @module @deepseek-ai/dsh-jobs-local/events + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeLayer } from '@deepseek-ai/dsh-scope' +import type { JobEvent, JobEventFilter, JobEventListener } from '@deepseek-ai/dsh-jobs' + +/** One registered listener and the filter it declared. */ +interface Subscription { + filter: JobEventFilter + listener: JobEventListener +} + +/** + * One scope's contributions: the job controllers attached from it and the + * `{ owners: 'scope' }` subscriptions registered there. Both tables are + * anonymous because a contribution is identified by its own disposer, never + * by a name a second registrant could shadow. + */ +export class JobLayer implements ScopeLayer { + readonly controllers = new AnonymousEntries() + readonly scoped = new AnonymousEntries() + + isEmpty(): boolean { + return this.controllers.isEmpty() && this.scoped.isEmpty() + } +} + +/** + * Routes events to subscriptions. `{ owner }` and `{ owners: 'all' }` + * subscriptions are evaluated against every event regardless of where they + * were registered; `{ owners: 'scope' }` subscriptions file into the + * registering context's scope layer and receive the owners composed under it + * — the global layer, reached from an unscoped context, receives every owner. + */ +export class JobEventHub { + private readonly unscoped = new Set() + + /** + * @param layers - the scope-layer table shared with the controller handshake. + * @param warn - sink for a listener's contained failure. + */ + constructor( + private readonly layers: ScopedLayers, + private readonly warn: (message: string) => void, + ) {} + + /** + * Register one listener as an effect of `ctx`. + * @param ctx - the subscribing context; owns the effect and, for `'scope'`, names the scope. + * @param filter - which owners' events to deliver. + * @param listener - receives each matching event. + * @returns disposer that unregisters the listener. + */ + subscribe(ctx: Context, filter: JobEventFilter, listener: JobEventListener): () => void { + const subscription: Subscription = { filter, listener } + if ('owners' in filter && filter.owners === 'scope') { + return this.layers.effect(ctx, layer => layer.scoped.append(subscription), { label: 'jobs.events.subscribe()' }) + } + return ctx.effect(() => { + this.unscoped.add(subscription) + return () => { this.unscoped.delete(subscription) } + }, 'jobs.events.subscribe()') + } + + /** + * Deliver one event to every matching subscription, containing each + * listener so an observer cannot break the commit already made. + * @param event - the event to deliver. + * @param owner - the job's exact owner, or undefined for unowned work. + */ + emit(event: JobEvent, owner: Agent | undefined): void { + const ownerId = owner?.id + for (const subscription of this.unscoped) { + const { filter } = subscription + if ('owner' in filter && ownerId !== undefined && ownerId !== filter.owner) continue + this.deliver(subscription, event) + } + for (const subscription of this.layers.global.scoped.values()) this.deliver(subscription, event) + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) { + for (const subscription of layer.scoped.values()) this.deliver(subscription, event) + } + } + + private deliver(subscription: Subscription, event: JobEvent): void { + try { + subscription.listener(event) + } catch (error: unknown) { + this.warn(`jobs: event listener threw on ${event.type}: ${String(error)}`) + } + } +} diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index e5415aee15..965de7a124 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -1,8 +1,8 @@ /** * Process-local provider for the background-job capability seam - * (`ctx.jobs`). It keeps every record — lifecycle state plus the optional - * bounded output ring — in memory and hands out fresh snapshots and chunk - * copies, never live state. + * (`ctx.jobs`). It keeps every job — lifecycle state, the bounded output + * ring, and the model cursor — in memory and hands out fresh projections and + * chunk copies, never live state. * * Registrations outlive producer and controller fibers. Agent or service * disposal cancels live work and awaits compliant producers; a throwing @@ -13,14 +13,18 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' -import type { ScopeLayer } from '@deepseek-ai/dsh-scope' +import { ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' +import type { SessionId } from '@deepseek-ai/dsh-session' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' import type { - JobAppendOptions, JobChannel, JobDoneListener, JobKind, JobOutcome, JobOutputListener, JobRead, - JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, RecordingJob, RunningJob, + JobAppendOptions, JobEvent, JobEvents, JobHandle, JobKillOptions, JobKind, JobOutcome, JobOutputRead, JobRead, + JobSettleCause, JobSpec, JobStatus, JobView, VisibleJobs, } from '@deepseek-ai/dsh-jobs' +import { JobEventHub, JobLayer } from './events.ts' +import { startPump } from './pump.ts' +import type { PumpHandle } from './pump.ts' +import { OutputRing } from './ring.ts' /** Timeout code that distinguishes a bounded wait from caller cancellation. */ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' @@ -28,12 +32,15 @@ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' /** Default maximum number of active jobs in one exact-owner bucket. */ const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10 -/** Default live record retention per job, in UTF-8 bytes. */ +/** Default live ring retention per job, in UTF-8 bytes. */ const DEFAULT_RETAIN_BYTES = 256 * 1024 -/** Default record retention kept after settlement, in UTF-8 bytes. */ +/** Default ring retention kept after settlement, in UTF-8 bytes. */ const DEFAULT_SETTLED_RETAIN_BYTES = 16 * 1024 +/** Default poll interval for pull sources, in milliseconds. */ +const DEFAULT_PUMP_POLL_MS = 150 + /** Configuration for the process-local job registry. */ export interface Config { /** @@ -41,49 +48,28 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number - /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + /** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */ retainBytes?: number - /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + /** Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ settledRetainBytes?: number -} - -/** One retained record ring entry; `bytes` caches the chunk's UTF-8 length. */ -interface RingChunk { - at: number - text: string - bytes: number - channel?: JobChannel - gapBefore?: true -} - -/** The bounded output ring behind one job's declared record. */ -interface RecordState { - /** Retained chunks in offset order; offsets stay absolute across eviction. */ - chunks: RingChunk[] - /** Sum of the retained chunks' byte lengths. */ - retainedBytes: number - /** Total UTF-8 bytes ever appended. */ - total: number - /** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */ - earliest: number + /** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */ + pumpPollMs?: number } /** - * Producer-written state shared between the {@link RunningJob} face and the + * Producer-written state shared between the {@link JobHandle} and the * registered record: the starter call writes through it before the commit, * the same object serves the job for its whole life afterwards. */ interface ProducerState { - /** Bounded output ring; undefined when the spec declared no record. */ - record: RecordState | undefined - /** Live progress detail until settlement replaces it with the terminal one. */ - detail: string | undefined + /** Live progress line until settlement clears it. */ + progress: string | undefined /** The committed registry record; undefined exactly during the starter call. */ - job: TrackedTask | undefined + job: TrackedJob | undefined } -/** The registry's mutable per-job record (never handed out — see {@link LocalJobRegistry.snapshot}). */ -interface TrackedTask { +/** The registry's mutable per-job record (never handed out — see {@link LocalJobRegistry.view}). */ +interface TrackedJob { id: JobId kind: JobKind label: string @@ -91,22 +77,31 @@ interface TrackedTask { /** Exact lifecycle owner; session-id authorization is derived from it. */ owner: Agent | undefined cancel: (reason?: string) => void - readOutput: (() => string) | undefined status: JobStatus - /** Producer-shared record ring and detail line. */ + ring: OutputRing + /** The model's consuming cursor; {@link VisibleJobs.readAt} never moves it. */ + modelCursor: number + /** Whether the first post-settlement read already handed out `result`. */ + resultDelivered: boolean + /** Producer-shared progress line and commit binding. */ state: ProducerState - output: string | undefined + /** Terminal reason; a recorded kill reason is merged in at settlement. */ + detail: string | undefined + result: string | undefined startedAt: number finishedAt: number | undefined - reported: boolean - /** Resolves once the terminal snapshot is recorded and listeners notified. */ + /** Reason recorded by {@link VisibleJobs.kill}, merged into a `killed` settlement's detail. */ + killReason: string | undefined + /** Set once a kill or teardown cancel ran; settlement reports it as the cause. */ + settleCause: JobSettleCause | undefined + /** Resolves once the terminal record is committed and announced. */ settled: Promise /** Resolver for {@link settled}, called by the first effective settlement. */ markSettled: () => void - /** Live waits; settlement with a waiter marks the job reported. */ - waiters: number /** Removable resolvers for live waits; timeout/abort unregister before the job settles. */ waitResolvers: Set<() => void> + /** The registry-owned pump over the spec's pull sources, when it named any. */ + pump: PumpHandle | undefined } /** True for the three terminal {@link JobStatus} values. */ @@ -114,42 +109,6 @@ function isTerminal(status: JobStatus): boolean { return status === 'completed' || status === 'killed' || status === 'failed' } -/** - * The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut - * advances past continuation bytes so the surviving text never starts inside - * a code point. - * @param text - the oversized chunk text. - * @param maxBytes - positive byte budget for the surviving tail. - * @returns the surviving tail and its exact byte length. - */ -function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } { - const raw = Buffer.from(text, 'utf8') - let start = raw.length - maxBytes - // The loop bound proves the index is in range; the assertion only - // discharges noUncheckedIndexedAccess. - while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1 - const tail = raw.subarray(start) - return { text: tail.toString('utf8'), bytes: tail.length } -} - -/** - * One scope's contributions: the job controllers attached from it and the - * completion, change, and record-output listeners registered there. All - * tables are anonymous because a contribution is identified by its own - * disposer, never by a name a second registrant could shadow. - */ -class JobLayer implements ScopeLayer { - readonly controllers = new AnonymousEntries() - readonly listeners = new AnonymousEntries() - readonly changed = new AnonymousEntries() - readonly output = new AnonymousEntries() - - isEmpty(): boolean { - return this.controllers.isEmpty() && this.listeners.isEmpty() && this.changed.isEmpty() - && this.output.isEmpty() - } -} - /** * The in-memory `jobs` registry. See the Service Definition contract in * `@deepseek-ai/dsh-jobs` for the ownership, isolation, and lifecycle @@ -172,20 +131,28 @@ export class LocalJobRegistry extends JobRegistry { .min(1) .max(Number.MAX_SAFE_INTEGER) .default(DEFAULT_SETTLED_RETAIN_BYTES), + pumpPollMs: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_PUMP_POLL_MS), }) /** Schemastery-defaulted active-job limit. */ private readonly maxConcurrentJobsPerOwner: number - /** Schemastery-defaulted live record retention cap. */ + /** Schemastery-defaulted live ring retention cap. */ private readonly retainBytes: number - /** Schemastery-defaulted settled record retention cap. */ + /** Schemastery-defaulted settled ring retention cap. */ private readonly settledRetainBytes: number - private store = new Map() + /** Schemastery-defaulted pull-source poll interval. */ + private readonly pumpPollMs: number + private store = new Map() private counters = new Map() /** - * Surfaces and listeners layered by the scope that registered them, in the - * tools-registry shape: a contribution files into its registering context's - * scope, and a read unions the global layer with the reader's scope chain. + * Controllers and scoped subscriptions layered by the scope that registered + * them, in the tools-registry shape: a contribution files into its + * registering context's scope, and a read unions the global layer with the + * owner's scope chain. * * The registry is one process-wide instance serving every composition, so a * flat table would answer a per-owner question process-wide: one preset's @@ -195,7 +162,7 @@ export class LocalJobRegistry extends JobRegistry { * layer, so change notification is a no-op. */ private readonly layers = new ScopedLayers(() => new JobLayer(), () => {}) - private listenersClosed = false + private readonly hub: JobEventHub /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ private ownerCleanups = new Map Promise | void>() /** Service context used by detached settlement continuations and teardown. */ @@ -204,15 +171,30 @@ export class LocalJobRegistry extends JobRegistry { constructor(ctx: Context, config: Config) { super(ctx) // Schemastery validates and fills the defaults before constructing the service. - this.maxConcurrentJobsPerOwner = (config as Required).maxConcurrentJobsPerOwner - this.retainBytes = (config as Required).retainBytes - this.settledRetainBytes = (config as Required).settledRetainBytes + const resolved = config as Required + this.maxConcurrentJobsPerOwner = resolved.maxConcurrentJobsPerOwner + this.retainBytes = resolved.retainBytes + this.settledRetainBytes = resolved.settledRetainBytes + this.pumpPollMs = resolved.pumpPollMs this.selfCtx = ctx + this.hub = new JobEventHub(this.layers, message => ctx.logger.warn(message)) ctx.effect(() => () => this.disposeAll(), 'jobs teardown') } - start(spec: JobStart): JobId { - if (!this.servesOwner(spec.owner)) { + /** + * The event stream bound to the accessing context: a subscription is an + * effect of that context, and `{ owners: 'scope' }` names its scope. + */ + get events(): JobEvents { + const registrar = this.ctx + return { + subscribe: (filter, listener) => this.hub.subscribe(registrar, filter, listener), + } + } + + start(spec: JobSpec): JobId { + const owner = this.resolveOwner(spec.owner) + if (!this.servesOwner(owner)) { throw new Error('background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)') } if (spec.kind.length === 0) throw new Error('invalid job kind: expected a non-empty string') @@ -221,9 +203,9 @@ export class LocalJobRegistry extends JobRegistry { && (!Number.isSafeInteger(spec.outputLimitBytes) || spec.outputLimitBytes <= 0)) { throw new Error(`invalid outputLimitBytes: expected a positive safe integer, got ${JSON.stringify(spec.outputLimitBytes)}`) } - if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) + if (owner !== undefined) this.ensureOwnerCleanup(owner) - const active = this.activeTaskCount(spec.owner) + const active = this.activeTaskCount(owner) if (active >= this.maxConcurrentJobsPerOwner) { throw new Error( `background job limit reached for this owner (limit: ${this.maxConcurrentJobsPerOwner}); use job_kill to stop an unneeded job, wait for it to finish, then retry`, @@ -236,189 +218,90 @@ export class LocalJobRegistry extends JobRegistry { const count = (this.counters.get(spec.kind) ?? 0) + 1 this.counters.set(spec.kind, count) const id = JobId(`${spec.kind}-${count}`) - const state: ProducerState = { record: undefined, detail: undefined, job: undefined } - const face: RunningJob = { + const ring = new OutputRing() + const state: ProducerState = { progress: undefined, job: undefined } + const handle: JobHandle = { id, - updateDetail: (detail) => { this.updateDetail(state, detail) }, + append: (text, options) => { this.appendRing(state, ring, text, options, 'producer') }, + updateProgress: (line) => { this.updateProgress(state, line) }, } - // The declaration decides the face: a plain start never receives `append`. - const hooks = spec.record === true ? spec.run(this.recordingFace(face, state)) : spec.run(face) + const hooks = spec.run(handle) let markSettled!: () => void const settled = new Promise((resolve) => { markSettled = resolve }) - const job: TrackedTask = { + const job: TrackedJob = { id, kind: spec.kind, label: spec.label, outputLimitBytes: spec.outputLimitBytes, - owner: spec.owner, + owner, cancel: hooks.cancel.bind(hooks), - readOutput: hooks.readOutput?.bind(hooks), status: 'running', + ring, + modelCursor: 0, + resultDelivered: false, state, - output: undefined, + detail: undefined, + result: undefined, startedAt: Date.now(), finishedAt: undefined, - reported: false, + killReason: undefined, + settleCause: undefined, settled, markSettled, - waiters: 0, waitResolvers: new Set(), + pump: undefined, } // Binding the shared producer state is the commit: writes staged inside - // the starter are already in `state`, and every later handle call reaches - // the registered record for its terminal checks and observer signals. + // the starter are already in the ring and `state`, and every later handle + // call reaches the registered record for its terminal checks and signals. state.job = job this.store.set(id, job) - void hooks.done.then( - (outcome) => { this.settle(job, outcome) }, - (error: unknown) => { + // The producer's settlement or a registry-forced one ends the pump; the + // pump's final drain then lands before this registry trims the ring. + const producerDone = hooks.done.then( + outcome => outcome, + (error: unknown): JobOutcome => { // Contain a producer contract violation (`done` rejected) so cleanup and waiters cannot hang. this.selfCtx.logger.warn(`jobs: job ${job.id} producer done promise rejected (producer contract violation): ${String(error)}`) - this.settle(job, { status: 'failed', detail: String(error) }) + return { status: 'failed', detail: String(error) } }, ) + if (spec.output !== undefined && spec.output.length > 0) { + job.pump = startPump( + spec.output, + (text, options) => { this.appendRing(state, ring, text, options, 'pump') }, + this.pumpPollMs, + Promise.race([producerDone, settled]), + ) + } + void producerDone.then(async (outcome) => { + if (job.pump !== undefined) await job.pump.done + this.settle(job, outcome, job.settleCause ?? 'producer') + }) // Registration is complete and cannot fail from here, so the visible set // has genuinely changed. - this.notifyChanged(job.owner) + this.emit({ type: 'registered', job: this.view(job) }, owner) return id } - list(caller?: Agent): JobSnapshot[] { - const session = caller?.id - return [...this.store.values()] - .filter(job => job.owner === undefined || job.owner.id === session) - .map(job => this.snapshot(job)) - } - - get(id: JobId, caller?: Agent): JobSnapshot { - const job = this.expect(id) - this.assertAccess(job, caller) - return this.snapshot(job) - } - - read(id: JobId, caller?: Agent): JobRead { - const job = this.expect(id) - this.assertAccess(job, caller) - const text = job.readOutput !== undefined - ? job.readOutput() - : isTerminal(job.status) ? job.output ?? '' : '' - if (isTerminal(job.status)) job.reported = true - return { text, snapshot: this.snapshot(job) } - } - - kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' { - const job = this.expect(id) - this.assertAccess(job, caller) - if (isTerminal(job.status)) { - job.reported = true - return 'already-finished' + visibleTo(caller?: SessionId): VisibleJobs { + const reach = (id: JobId): TrackedJob => { + const job = this.expect(id) + this.assertAccess(job, caller) + return job } - // Cancel first so a throw leaves both lifecycle and notice state unchanged. - job.cancel(reason) - job.status = 'stopping' - job.reported = true - this.notifyChanged(job.owner) - return 'requested' - } - - async wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise { - const job = this.expect(id) - this.assertAccess(job, caller) - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`) + return { + list: () => [...this.store.values()] + .filter(job => job.owner === undefined || job.owner.id === caller) + .map(job => this.view(job)), + get: id => this.view(reach(id)), + read: id => this.readJob(reach(id)), + readAt: (id, from) => this.readAt(reach(id), from), + kill: (id, options) => this.killJob(reach(id), options), + wait: async (id, timeoutMs, signal) => this.waitJob(reach(id), timeoutMs, signal), } - if (!isTerminal(job.status)) { - if (signal?.aborted) throw new Error('wait aborted') - // Abort removes the waiter synchronously so same-tick settlement cannot - // suppress a notice for a wait that will reject. - job.waiters += 1 - let counted = true - const uncount = (): void => { - if (!counted) return - counted = false - job.waiters -= 1 - } - try { - // The scoped deadline distinguishes a successful wait timeout from - // caller cancellation and clears its timer on every exit. - using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT) - await new Promise((resolve, reject) => { - const onSettled = (): void => { - job.waitResolvers.delete(onSettled) - d.signal.removeEventListener('abort', onAbort) - resolve() - } - const onAbort = (): void => { - job.waitResolvers.delete(onSettled) - // A settled job cannot reach here: settlement releases every waiter - // before it announces completion, and each released waiter detaches - // this listener in the same synchronous span, so nothing that reacts - // to a settlement can abort a wait the settlement already owed. - if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) { - resolve() - } else { - uncount() - reject(new Error('wait aborted')) - } - } - job.waitResolvers.add(onSettled) - d.signal.addEventListener('abort', onAbort, { once: true }) - }) - } finally { - uncount() - } - } - if (isTerminal(job.status)) job.reported = true - return this.snapshot(job) - } - - onJobDone(listener: JobDoneListener): () => void { - return this.layers.effect( - this.ctx, - layer => layer.listeners.append(listener), - { label: 'jobs.onJobDone()' }, - ) - } - - onJobsChanged(listener: JobsChangedListener): () => void { - return this.layers.effect( - this.ctx, - layer => layer.changed.append(listener), - { label: 'jobs.onJobsChanged()' }, - ) - } - - readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead { - const job = this.expect(id) - this.assertAccess(job, caller) - if (!Number.isSafeInteger(from) || from < 0) { - throw new Error(`invalid record read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`) - } - const record = job.state.record - if (record === undefined) { - throw new Error(`job ${job.id} declared no output record`) - } - const chunks: JobRecordChunk[] = [] - for (const chunk of record.chunks) { - if (chunk.at + chunk.bytes <= from) continue - chunks.push({ - at: chunk.at, - text: chunk.text, - ...chunk.channel !== undefined ? { channel: chunk.channel } : {}, - ...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {}, - }) - } - return { chunks, next: record.total, lossy: from < record.earliest } - } - - onOutput(listener: JobOutputListener): () => void { - return this.layers.effect( - this.ctx, - layer => layer.output.append(listener), - { label: 'jobs.onOutput()' }, - ) } attachController(name: string): () => void { @@ -431,6 +314,24 @@ export class LocalJobRegistry extends JobRegistry { ) } + /** + * Resolve a spec's owner session to its live Agent. An owned registration + * needs the agent registry, and the session must currently have a live + * instance: that instance's disposal is what cancels and drops the job. + */ + private resolveOwner(session: SessionId | undefined): Agent | undefined { + if (session === undefined) return undefined + const agents = this.selfCtx.get('agents') + if (agents === undefined) { + throw new Error('background job ownership requires the agent registry (load @deepseek-ai/dsh-agent)') + } + const owner = agents.get(session) + if (owner === undefined) { + throw new Error(`session "${session}" has no live agent (background job owner must be live)`) + } + return owner + } + /** * Whether an attached job controller can collect and stop work owned by * `owner`. The global layer holds every controller attached from an unscoped @@ -454,22 +355,8 @@ export class LocalJobRegistry extends JobRegistry { return count } - /** - * The completion listeners that own `owner`'s notices: the global layer's - * first, then each scoped layer along the owner's chain. A listener outside - * that chain belongs to another composition and must not deliver, or the - * owner reads one notice per mounted preset. - * @param owner - the settled job's owner, or undefined for unowned work. - * @returns the listeners to notify, in registration order per layer. - */ - private *listenersFor(owner?: Agent): IterableIterator { - yield* this.layers.global.listeners.values() - const scope = owner === undefined ? undefined : scopeOf(owner.ctx) - for (const layer of this.layers.chainLayers(scope)) yield* layer.listeners.values() - } - /** Look up a job or fail loud. */ - private expect(id: JobId): TrackedTask { + private expect(id: JobId): TrackedJob { const job = this.store.get(id) if (job === undefined) throw new Error(`unknown job ${id}`) return job @@ -478,214 +365,189 @@ export class LocalJobRegistry extends JobRegistry { /** * The isolation fence: a job with an owner is reachable only by callers * whose session id matches (`!== undefined` semantics — an unowned job is - * open, and a no-agent caller can never match an owned one). + * open, and a caller-less view can never match an owned one). */ - private assertAccess(job: TrackedTask, caller?: Agent): void { - if (job.owner !== undefined && job.owner.id !== caller?.id) { + private assertAccess(job: TrackedJob, caller: SessionId | undefined): void { + if (job.owner !== undefined && job.owner.id !== caller) { throw new Error(`job ${job.id} belongs to another session`) } } - /** Project a fresh read-only snapshot from the mutable record. */ - private snapshot(job: TrackedTask): JobSnapshot { - const ownerSession = job.owner?.id - const record = job.state.record + /** Project a fresh read-only view from the mutable record. */ + private view(job: TrackedJob): JobView { + const owner = job.owner?.id return { id: job.id, kind: job.kind, label: job.label, + ...owner !== undefined ? { owner } : {}, ...job.outputLimitBytes !== undefined ? { outputLimitBytes: job.outputLimitBytes } : {}, - ...ownerSession !== undefined ? { ownerSession } : {}, status: job.status, - ...job.state.detail !== undefined ? { detail: job.state.detail } : {}, + ...job.state.progress !== undefined ? { progress: job.state.progress } : {}, + ...job.detail !== undefined ? { detail: job.detail } : {}, startedAt: job.startedAt, ...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {}, - reported: job.reported, - ...record !== undefined ? { outputTotal: record.total, outputEarliest: record.earliest } : {}, + output: { total: job.ring.total, earliest: job.ring.earliest }, } } - /** - * The change observers that own `owner`'s updates, resolved exactly like - * {@link listenersFor}: the global layer — a host composition's own carrier, - * which serves every owner — then each scoped layer along the owner's chain. - * An observer outside that chain belongs to another composition and would - * otherwise be told about agents it does not compose. - * @param owner - the owner whose visible set moved, or undefined for unowned work. - * @returns the observers to notify, in registration order per layer. - */ - private *changedFor(owner?: Agent): IterableIterator { - yield* this.layers.global.changed.values() - const scope = owner === undefined ? undefined : scopeOf(owner.ctx) - for (const layer of this.layers.chainLayers(scope)) yield* layer.changed.values() + private emit(event: JobEvent, owner: Agent | undefined): void { + this.hub.emit(event, owner) } - /** - * Announce that one owner's visible set changed. Each listener is contained - * so an observer cannot break a lifecycle commit that already happened. - */ - private notifyChanged(owner: Agent | undefined): void { - for (const listener of this.changedFor(owner)) { - try { - listener(owner) - } catch (error: unknown) { - this.selfCtx.logger.warn(`jobs: onJobsChanged listener threw: ${String(error)}`) - } - } - } - - /** The record-output observers that own `owner`'s stream signals, resolved like {@link changedFor}. */ - private *outputFor(owner?: Agent): IterableIterator { - yield* this.layers.global.output.values() - const scope = owner === undefined ? undefined : scopeOf(owner.ctx) - for (const layer of this.layers.chainLayers(scope)) yield* layer.output.values() - } - - /** Announce that one job's record advanced (append or settlement), with per-listener containment. */ - private notifyOutput(job: TrackedTask): void { - for (const listener of this.outputFor(job.owner)) { - try { - listener(job.id) - } catch (error: unknown) { - this.selfCtx.logger.warn(`jobs: onOutput listener threw: ${String(error)}`) - } - } - } - - /** Install the job's record and extend the producer face with its append. */ - private recordingFace(face: RunningJob, state: ProducerState): RecordingJob { - const record: RecordState = { chunks: [], retainedBytes: 0, total: 0, earliest: 0 } - state.record = record + /** Consume the ring from the model cursor; the result rides the first read after settlement. */ + private readJob(job: TrackedJob): JobRead { + const read = job.ring.readFrom(job.modelCursor) + job.modelCursor = job.ring.total + const result = isTerminal(job.status) && !job.resultDelivered ? job.result : undefined + if (result !== undefined) job.resultDelivered = true return { - ...face, - append: (text, options) => { this.appendRecord(state, record, text, options) }, + chunks: read.chunks, + lossy: read.lossy, + ...result !== undefined ? { result } : {}, + job: this.view(job), } } + private readAt(job: TrackedJob, from: number): JobOutputRead { + if (!Number.isSafeInteger(from) || from < 0) { + throw new Error(`invalid output read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`) + } + return job.ring.readFrom(from) + } + + private killJob(job: TrackedJob, options?: JobKillOptions): 'requested' | 'already-finished' { + if (isTerminal(job.status)) return 'already-finished' + // Cancel first so a throw leaves lifecycle state unchanged. + job.cancel(options?.reason) + job.status = 'stopping' + // Last writer wins on purpose: the detail reports the latest kill intent. + if (options?.reason !== undefined) job.killReason = options.reason + job.settleCause = 'kill' + this.emit({ type: 'stopping', job: this.view(job) }, job.owner) + return 'requested' + } + + private async waitJob(job: TrackedJob, timeoutMs: number, signal?: AbortSignal): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`) + } + if (!isTerminal(job.status)) { + if (signal?.aborted) throw new Error('wait aborted') + // The scoped deadline distinguishes a successful wait timeout from + // caller cancellation and clears its timer on every exit. + using d = deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT) + await new Promise((resolve, reject) => { + const onSettled = (): void => { + job.waitResolvers.delete(onSettled) + d.signal.removeEventListener('abort', onAbort) + resolve() + } + const onAbort = (): void => { + job.waitResolvers.delete(onSettled) + // A settled job cannot reach here: settlement releases every waiter + // before it announces, and each released waiter detaches this + // listener in the same synchronous span, so nothing that reacts to a + // settlement can abort a wait the settlement already owed. + if (timeoutOf(d.signal, TASK_WAIT_TIMEOUT) !== undefined) { + resolve() + } else { + reject(new Error('wait aborted')) + } + } + job.waitResolvers.add(onSettled) + d.signal.addEventListener('abort', onAbort, { once: true }) + }) + } + return this.view(job) + } + /** - * Append one chunk through a recording producer face. A chunk against a - * settled job is logged and dropped; an empty chunk is dropped silently. A - * chunk staged inside the starter call is retained and signals no observer — - * the registration commit publishes it. + * Append one chunk to the ring. A producer chunk against a settled job is + * logged and dropped; the registry's own pump drains silently after + * settlement (a forced settlement may precede the producer's). A chunk + * staged inside the starter call is retained and signals no observer — the + * registration commit publishes it. */ - private appendRecord(state: ProducerState, record: RecordState, text: string, options?: JobAppendOptions): void { + private appendRing( + state: ProducerState, + ring: OutputRing, + text: string, + options: JobAppendOptions | undefined, + writer: 'producer' | 'pump', + ): void { const job = state.job if (job !== undefined && isTerminal(job.status)) { - this.selfCtx.logger.warn(`jobs: append to settled job ${job.id} dropped`) + if (writer === 'producer') this.selfCtx.logger.warn(`jobs: append to settled job ${job.id} dropped`) return } - if (text.length === 0) return - const bytes = Buffer.byteLength(text, 'utf8') - record.chunks.push({ - at: record.total, - text, - bytes, - ...options?.channel !== undefined ? { channel: options.channel } : {}, - ...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {}, - }) - record.total += bytes - record.retainedBytes += bytes - this.trimRecord(record, this.retainBytes) - if (job !== undefined) this.notifyOutput(job) + if (!ring.append(text, options, this.retainBytes)) return + if (job !== undefined) this.emitOutput(job) + } + + /** Announce that one job's ring advanced (append or settlement). */ + private emitOutput(job: TrackedJob): void { + const owner = job.owner?.id + this.emit({ type: 'output', id: job.id, ...owner !== undefined ? { owner } : {}, total: job.ring.total }, job.owner) } /** - * Replace the live detail line through a producer face; a write against a + * Replace the live progress line through a producer face; a write against a * settled job is logged and dropped. A write staged inside the starter call - * seeds the registered snapshot and signals no observer. + * seeds the registered projection and signals no observer. */ - private updateDetail(state: ProducerState, detail: string): void { + private updateProgress(state: ProducerState, line: string): void { const job = state.job if (job !== undefined && isTerminal(job.status)) { - this.selfCtx.logger.warn(`jobs: detail update on settled job ${job.id} dropped`) + this.selfCtx.logger.warn(`jobs: progress update on settled job ${job.id} dropped`) return } - state.detail = detail - if (job !== undefined) this.notifyChanged(job.owner) + state.progress = line + if (job !== undefined) this.emit({ type: 'progress', job: this.view(job) }, job.owner) } /** - * Drop retained head chunks until the ring fits `cap`; a single oversized - * chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. Offsets - * stay absolute: `earliest` advances over everything dropped. + * Record the first terminal outcome, release waiters, then announce the + * settlement. First-wins preserves a teardown force-failure against late + * producer settlement. The settled event follows every released waiter, so + * a consumer that claims while waiting always claims before the event. */ - private trimRecord(record: RecordState, cap: number): void { - while (record.retainedBytes > cap && record.chunks.length > 1) { - const dropped = record.chunks.shift() - /* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */ - if (dropped === undefined) break - record.retainedBytes -= dropped.bytes - } - const single = record.chunks.length === 1 ? record.chunks[0] : undefined - if (single !== undefined && single.bytes > cap) { - const tail = utf8Tail(single.text, cap) - single.at += single.bytes - tail.bytes - single.text = tail.text - single.bytes = tail.bytes - single.gapBefore = true - record.retainedBytes = tail.bytes - } - record.earliest = record.chunks[0]?.at ?? record.total - } - - /** - * Record the first terminal outcome, release waiters, then announce - * completion. First-wins preserves a teardown force-failure against late - * producer settlement. Pending waits mark the job reported before listeners - * run. Completion is announced last because a reporter may open a model turn - * synchronously: every other observer of this settlement must already have - * seen the committed record. - */ - private settle(job: TrackedTask, outcome: JobOutcome): void { + private settle(job: TrackedJob, outcome: JobOutcome, cause: JobSettleCause): void { if (isTerminal(job.status)) return job.status = outcome.status - // A terminal detail replaces the live progress line; without one the last - // progress line stays, still describing what the job was doing. - if (outcome.detail !== undefined) job.state.detail = outcome.detail - job.output = outcome.output + // A killed settlement carries the recorded kill reason in its detail: + // producer facts first (`signal: SIGTERM; cancelled by the user`). A job + // that outran its kill request (settled `completed`/`failed`) keeps the + // producer detail alone — the reason describes a kill that never landed. + if (outcome.status === 'killed' && job.killReason !== undefined) { + job.detail = outcome.detail !== undefined + ? `${outcome.detail}; ${job.killReason}` + : job.killReason + } else if (outcome.detail !== undefined) { + job.detail = outcome.detail + } + job.state.progress = undefined + job.result = outcome.result job.finishedAt = Date.now() - if (job.waiters > 0) job.reported = true - // Settlement ends the record: trim to the settled cap before any observer - // reads the terminal snapshot. - const record = job.state.record - if (record !== undefined) this.trimRecord(record, this.settledRetainBytes) - const snapshot = this.snapshot(job) + // Settlement ends the stream: trim to the settled cap before any observer + // reads the terminal projection. + job.ring.trim(this.settledRetainBytes) const waitResolvers = [...job.waitResolvers] job.waitResolvers.clear() for (const resolveWait of waitResolvers) resolveWait() job.markSettled() - this.notifyChanged(job.owner) - // The record stream ends with settlement; the signal follows the committed - // visible-set change and precedes completion listeners, which stay last. - if (record !== undefined) this.notifyOutput(job) - if (this.listenersClosed) return - for (const listener of this.listenersFor(job.owner)) { - try { - const returned = listener(snapshot, job.owner) - void Promise.resolve(returned).catch((error: unknown) => { - this.selfCtx.logger.warn(`jobs: onJobDone listener rejected for ${job.id}: ${String(error)}`) - }) - } catch (error: unknown) { - this.selfCtx.logger.warn(`jobs: onJobDone listener threw for ${job.id}: ${String(error)}`) - } - } + this.emit({ type: 'settled', job: this.view(job), cause }, job.owner) + // The ring's stream ends with settlement; the signal follows the committed + // settlement so an observer that wakes on it reads the terminal state. + this.emitOutput(job) } /** * Attach one awaited cleanup through the exact owner's scope. This survives * producer reloads and joins agent quiescence; the retained disposer lets - * service teardown detach the cross-fiber effect. Fails when the registry is - * absent or the owner is not its currently registered instance. + * service teardown detach the cross-fiber effect. */ private ensureOwnerCleanup(owner: Agent): void { - const ownerId = owner.id - const agents = this.selfCtx.get('agents') - if (agents === undefined) { - throw new Error('background job ownership requires the agent registry (load @deepseek-ai/dsh-agent)') - } - if (agents.get(ownerId) !== owner) { - throw new Error(`agent "${ownerId}" is not the registered agent instance (background job owner must be live)`) - } if (this.ownerCleanups.has(owner)) return // Record only after attach succeeds; a disposing scope rejects new effects. const detach = owner.ctx.effect(() => async () => { @@ -700,31 +562,30 @@ export class LocalJobRegistry extends JobRegistry { const owned = [...this.store.values()].filter(job => job.owner === owner) this.cancelForTeardown(owned, 'owner disposed') await Promise.all(owned.map(job => job.settled)) - for (const job of owned) this.store.delete(job.id) - // Removal is the one visible-set change no per-job record carries, so it - // must be announced here or an observer keeps the dropped rows forever. - if (owned.length > 0) this.notifyChanged(owner) + this.remove(owned) + } + + /** Drop settled records and announce each removal, the one visible-set change no per-job record carries. */ + private remove(jobs: readonly TrackedJob[]): void { + for (const job of jobs) { + this.store.delete(job.id) + this.emit({ type: 'removed', job: this.view(job) }, job.owner) + } } /** - * Close listeners, cancel live jobs, await settlement, and detach owner + * Cancel live jobs, await settlement, drop every record, and detach owner * effects. Throwing cancels are force-failed to avoid teardown deadlock. */ private async disposeAll(): Promise { - // The flag is the whole guard: each layer entry's undo belongs to the fiber - // that registered it, so this service may not drop them on its own way out. - this.listenersClosed = true const all = [...this.store.values()] this.cancelForTeardown(all, 'jobs service disposed') await Promise.all(all.map(job => job.settled)) - // Distinct owners whose records just disappeared. A change observer files - // into the layer of the context that registered it, so a consumer mounted - // outside this service — the api-proxy carrier registers from the mux - // stream — is still reachable here. Without this it keeps the rows it last - // received after a registry reload. - const emptied = new Set(all.map(job => job.owner)) - this.store.clear() - for (const owner of emptied) this.notifyChanged(owner) + // A subscriber mounted outside this service — the job controller's rows + // stream registers from its own context — is still reachable here. + // Without the removals it keeps the rows it last received after a + // registry reload. + this.remove(all) // Detach cross-fiber owner effects after the shared store is quiescent. const ownerCleanups = [...this.ownerCleanups.values()] this.ownerCleanups.clear() @@ -736,28 +597,24 @@ export class LocalJobRegistry extends JobRegistry { * force-fails the record and reports a possible orphan; a cancel that returns * without settling remains indistinguishable from a slow stop and may stall. */ - private cancelForTeardown(jobs: TrackedTask[], reason: string): void { + private cancelForTeardown(jobs: TrackedJob[], reason: string): void { for (const job of jobs) { if (isTerminal(job.status)) continue - // Teardown cancellation is a kill without a caller, so it claims the - // terminal report the same way `kill()` does. Nothing will read a notice - // for a job whose owner or service is being destroyed, and a waking - // reporter would spend a model request per teardown layer. This is - // decided before the producer runs: the force-failure below settles the - // record too, so a throwing cancel must not be the one path that - // announces an unreported completion into a disposing owner. - job.reported = true + // Whatever settles this job from here on, its owner or the service is + // being destroyed: the settlement announces `teardown` so a completion + // reporter does not address a reader that no longer exists. + job.settleCause = 'teardown' try { job.cancel(reason) job.status = 'stopping' // Teardown reaches settlement only after the producer releases, which a // slow stop can defer; announcing the transition here is what keeps an // observer from showing `running` for that whole window. - this.notifyChanged(job.owner) + this.emit({ type: 'stopping', job: this.view(job) }, job.owner) } catch (error: unknown) { const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}` this.selfCtx.logger.warn(`jobs: cancel of ${job.id} threw during teardown; job record forced failed and work may be orphaned: ${String(error)}`) - this.settle(job, { status: 'failed', detail }) + this.settle(job, { status: 'failed', detail }, 'teardown') } } } diff --git a/packages/jobs/jobs-local/tests/events.spec.ts b/packages/jobs/jobs-local/tests/events.spec.ts new file mode 100644 index 0000000000..b77a4e5c8c --- /dev/null +++ b/packages/jobs/jobs-local/tests/events.spec.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { ScopedLayers, bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' +import { JobId } from '@deepseek-ai/dsh-jobs' +import type { JobEvent, JobView } from '@deepseek-ai/dsh-jobs' +import { JobEventHub, JobLayer } from '../src/events.ts' + +function agentIn(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { + const id = SessionId(rawId) + let agentCtx = ctx.plugin(() => {}).ctx + if (presetScope !== undefined) { + const key = {} + bindScopeParent(key, presetScope) + agentCtx = createScope(agentCtx, key).ctx + } + const session = Session.create(id) + return { + id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: agentCtx, + } as unknown as Agent +} + +function view(owner: Agent | undefined): JobView { + return { + id: JobId('bash-1'), + kind: 'bash', + label: 'x', + ...owner !== undefined ? { owner: owner.id } : {}, + status: 'running', + startedAt: 0, + output: { total: 0, earliest: 0 }, + } +} + +function bench() { + const ctx = new Context() + const warnings: string[] = [] + const layers = new ScopedLayers(() => new JobLayer(), () => {}) + const hub = new JobEventHub(layers, message => warnings.push(message)) + return { ctx, hub, warnings } +} + +describe('JobEventHub', () => { + it('delivers by owner: that session plus every unowned job, never another session', () => { + const { ctx, hub } = bench() + const alice = agentIn(ctx, 'alice') + const bob = agentIn(ctx, 'bob') + const seen: (string | undefined)[] = [] + hub.subscribe(ctx, { owner: alice.id }, (event) => { seen.push(event.type === 'output' ? event.owner : event.job.owner) }) + + hub.emit({ type: 'registered', job: view(alice) }, alice) + hub.emit({ type: 'registered', job: view(bob) }, bob) + hub.emit({ type: 'registered', job: view(undefined) }, undefined) + hub.emit({ type: 'output', id: JobId('bash-1'), owner: bob.id, total: 1 }, bob) + hub.emit({ type: 'output', id: JobId('bash-1'), total: 1 }, undefined) + expect(seen).toEqual(['alice', undefined, undefined]) + }) + + it("delivers 'all' everything and 'scope' only the owners composed under the subscriber", async () => { + const { ctx, hub } = bench() + const mount = createScope(ctx, {}) + const otherMount = createScope(ctx, {}) + const alice = agentIn(ctx, 'alice', scopeOf(mount.ctx)) + const bob = agentIn(ctx, 'bob', scopeOf(otherMount.ctx)) + + const all: string[] = [] + hub.subscribe(ctx, { owners: 'all' }, (event) => { all.push(event.type === 'output' ? String(event.owner) : String(event.job.owner)) }) + const scoped: string[] = [] + await mount.ctx.plugin((pluginCtx: Context) => { + hub.subscribe(pluginCtx, { owners: 'scope' }, (event) => { scoped.push(event.type === 'output' ? String(event.owner) : String(event.job.owner)) }) + }) + const unscoped: string[] = [] + hub.subscribe(ctx, { owners: 'scope' }, (event) => { unscoped.push(event.type === 'output' ? String(event.owner) : String(event.job.owner)) }) + + hub.emit({ type: 'registered', job: view(alice) }, alice) + hub.emit({ type: 'registered', job: view(bob) }, bob) + hub.emit({ type: 'registered', job: view(undefined) }, undefined) + expect(all).toEqual(['alice', 'bob', 'undefined']) + // A preset-scoped subscription hears its own agents only; an unowned job + // has no chain to walk, so only unscoped subscriptions hear it. + expect(scoped).toEqual(['alice']) + expect(unscoped).toEqual(['alice', 'bob', 'undefined']) + }) + + it('contains a throwing listener and keeps delivering to later ones', () => { + const { ctx, hub, warnings } = bench() + const seen: JobEvent[] = [] + hub.subscribe(ctx, { owners: 'all' }, () => { throw new Error('listener boom') }) + hub.subscribe(ctx, { owners: 'all' }, (event) => { seen.push(event) }) + hub.emit({ type: 'registered', job: view(undefined) }, undefined) + expect(seen).toHaveLength(1) + expect(warnings).toEqual([expect.stringContaining('event listener threw on registered: Error: listener boom')]) + }) + + it('unregisters through the disposer and with the subscribing fiber', async () => { + const { ctx, hub } = bench() + const seen: number[] = [] + const detach = hub.subscribe(ctx, { owners: 'all' }, () => { seen.push(1) }) + const fiber = await ctx.plugin((pluginCtx: Context) => { + hub.subscribe(pluginCtx, { owners: 'scope' }, () => { seen.push(2) }) + hub.subscribe(pluginCtx, { owner: SessionId('nobody') }, () => { seen.push(3) }) + }) + + // Filter-kind order is not a contract; the assertion only fixes it for determinism. + hub.emit({ type: 'registered', job: view(undefined) }, undefined) + expect(seen).toEqual([1, 3, 2]) + detach() + detach() + hub.emit({ type: 'registered', job: view(undefined) }, undefined) + expect(seen).toEqual([1, 3, 2, 3, 2]) + await fiber.dispose() + hub.emit({ type: 'registered', job: view(undefined) }, undefined) + expect(seen).toEqual([1, 3, 2, 3, 2]) + }) + + it('keeps a scoped layer alive only while a subscription or controller remains in it', async () => { + const { ctx, hub } = bench() + const mount = createScope(ctx, {}) + const spy = vi.fn() + const fiber = await mount.ctx.plugin((pluginCtx: Context) => { + hub.subscribe(pluginCtx, { owners: 'scope' }, spy) + }) + const alice = agentIn(ctx, 'alice', scopeOf(mount.ctx)) + hub.emit({ type: 'registered', job: view(alice) }, alice) + expect(spy).toHaveBeenCalledTimes(1) + await fiber.dispose() + hub.emit({ type: 'registered', job: view(alice) }, alice) + expect(spy).toHaveBeenCalledTimes(1) + expect(new JobLayer().isEmpty()).toBe(true) + }) +}) diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 5bcb9b156e..550a713f6b 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -6,7 +6,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RecordingJob } from '@deepseek-ai/dsh-jobs' +import type { + JobEvent, JobEventFilter, JobHandle, JobHooks, JobKind, JobOutcome, JobOutputSource, JobSpec, JobView, VisibleJobs, +} from '@deepseek-ai/dsh-jobs' import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local' declare module '@deepseek-ai/dsh-jobs' { @@ -55,25 +57,36 @@ async function disposeAgentScope(agent: Agent): Promise { await dispose() } -/** A controllable producer start-spec: settle its `done` on demand, record cancels, expose the job face. */ -function producer(overrides: Partial & JobHooks> = {}) { +/** Register a stub agent so the registry can resolve it as a live owner. */ +function liveAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { + const agent = stubAgent(ctx, rawId, presetScope) + ctx.agents.register(agent) + return agent +} + +type ProducerOverrides = Partial & JobHooks> & { + owner?: Agent +} + +/** A controllable producer spec: settle its `done` on demand, record cancels, expose the handle. */ +function producer(overrides: ProducerOverrides = {}) { let settle!: (outcome: JobOutcome) => void let reject!: (error: unknown) => void const cancels: (string | undefined)[] = [] - const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, record, ...hookOverrides } = overrides + const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, output, ...hookOverrides } = overrides const hooks: JobHooks = { cancel(reason) { cancels.push(reason) }, done: new Promise((res, rej) => { settle = res; reject = rej }), ...hookOverrides, } - let started: RecordingJob | undefined - const spec: JobStart = { + let started: JobHandle | undefined + const spec: JobSpec = { kind, label, - ...owner !== undefined ? { owner } : {}, + ...owner !== undefined ? { owner: owner.id } : {}, ...outputLimitBytes !== undefined ? { outputLimitBytes } : {}, - ...record !== undefined ? { record } : {}, - run: (job: RecordingJob) => { + ...output !== undefined ? { output } : {}, + run: (job) => { started = job return hooks }, @@ -83,7 +96,7 @@ function producer(overrides: Partial & JobHooks> = {}) { settle, reject, cancels, - job(): RecordingJob { + job(): JobHandle { if (started === undefined) throw new Error('producer not started') return started }, @@ -98,12 +111,24 @@ async function harness(config: JobsConfig = {}) { return ctx } +/** The caller-bound view for an agent, or the anonymous view. */ +function jobsOf(ctx: Context, agent?: Agent): VisibleJobs { + return ctx.jobs.visibleTo(agent?.id) +} + +/** Collect events matching `filter`; `types` narrows what is recorded. */ +function collect(ctx: Context, filter: JobEventFilter = { owners: 'all' }, types?: JobEvent['type'][]): JobEvent[] { + const seen: JobEvent[] = [] + ctx.jobs.events.subscribe(filter, (event) => { + if (types === undefined || types.includes(event.type)) seen.push(event) + }) + return seen +} + /** * Attach a job controller the way `tool-jobs` does: from a plugin whose own * `inject` resolves `ctx.jobs`, so the service method binds to the REGISTERING - * context and the controller files into that context's scope layer. Reading the - * service off a bare scoped context instead throws `cannot get property "jobs" - * without inject`, which is the same rule the shipped plugin obeys. + * context and the controller files into that context's scope layer. * @param ctx - the context whose scope should own the controller. */ async function attachControllerIn(ctx: Context): Promise { @@ -124,9 +149,22 @@ function waitResolverCount(ctx: Context, id: JobId): number { return job.waitResolvers.size } +/** A scripted pull source: each read shifts the next scripted result. */ +function scriptedSource(reads: { text: string; lossy?: boolean }[], channel?: 'stdout' | 'stderr'): JobOutputSource { + let offset = 0 + return { + ...channel !== undefined ? { channel } : {}, + read() { + const next = reads.shift() ?? { text: '' } + offset += Buffer.byteLength(next.text, 'utf8') + return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } + }, + } +} + describe('LocalJobRegistry.start', () => { - it('preserves the SessionId brand on public owner snapshots', () => { - expectTypeOf().toEqualTypeOf() + it('preserves the SessionId brand on public owner projections', () => { + expectTypeOf().toEqualTypeOf() }) it('refuses to register while no job controller serves the owner', async () => { @@ -146,10 +184,8 @@ describe('LocalJobRegistry.start', () => { const withoutControls = createScope(ctx, {}) await attachControllerIn(withControls.ctx) - const served = stubAgent(ctx, 'served', scopeOf(withControls.ctx)) - const unserved = stubAgent(ctx, 'unserved', scopeOf(withoutControls.ctx)) - ctx.agents.register(served) - ctx.agents.register(unserved) + const served = liveAgent(ctx, 'served', scopeOf(withControls.ctx)) + const unserved = liveAgent(ctx, 'unserved', scopeOf(withoutControls.ctx)) expect(() => ctx.jobs.start(producer({ owner: served }).spec)).not.toThrow() expect(() => ctx.jobs.start(producer({ owner: unserved }).spec)) @@ -166,8 +202,7 @@ describe('LocalJobRegistry.start', () => { // The host-plane composition's own controls: no scope, so the global layer // holds them and every owner's read includes it. await attachControllerIn(ctx) - const scoped = stubAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx)) - ctx.agents.register(scoped) + const scoped = liveAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx)) expect(() => ctx.jobs.start(producer({ owner: scoped }).spec)).not.toThrow() expect(() => ctx.jobs.start(producer().spec)).not.toThrow() @@ -189,6 +224,11 @@ describe('LocalJobRegistry.start', () => { }, ) + it('rejects a non-positive pump interval', async () => { + const ctx = new Context() + await expect(ctx.plugin(LocalJobRegistry, { pumpPollMs: 0 })).rejects.toThrow() + }) + it('accepts the largest safe integer limit', async () => { const ctx = await harness({ maxConcurrentJobsPerOwner: Number.MAX_SAFE_INTEGER }) expect(ctx.jobs).toBeInstanceOf(LocalJobRegistry) @@ -200,7 +240,7 @@ describe('LocalJobRegistry.start', () => { for (const job of live) ctx.jobs.start(job.spec) const blocked = producer() - const run = vi.fn((job: RecordingJob) => blocked.spec.run(job)) + const run = vi.fn((job: JobHandle) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('background job limit reached for this owner (limit: 10)') expect(run).not.toHaveBeenCalled() @@ -213,7 +253,7 @@ describe('LocalJobRegistry.start', () => { expect(ctx.jobs.start(first.spec)).toBe('bash-1') const blocked = producer() - const run = vi.fn((job: RecordingJob) => blocked.spec.run(job)) + const run = vi.fn((job: JobHandle) => blocked.spec.run(job)) expect(() => ctx.jobs.start({ ...blocked.spec, run })) .toThrow('use job_kill to stop an unneeded job, wait for it to finish, then retry') expect(run).not.toHaveBeenCalled() @@ -227,7 +267,7 @@ describe('LocalJobRegistry.start', () => { const ctx = await harness({ maxConcurrentJobsPerOwner: 1 }) const first = producer() const id = ctx.jobs.start(first.spec) - expect(ctx.jobs.kill(id)).toBe('requested') + expect(jobsOf(ctx).kill(id)).toBe('requested') const replacement = producer() expect(() => ctx.jobs.start(replacement.spec)).toThrow('(limit: 1)') @@ -249,17 +289,18 @@ describe('LocalJobRegistry.start', () => { }, ) - it('isolates exact owners, replacement objects with the same session id, and the unowned bucket', async () => { + it('buckets by exact owner: a same-session replacement and the unowned bucket count separately', async () => { const ctx = await harness({ maxConcurrentJobsPerOwner: 1 }) const oldOwner = stubAgent(ctx, 'shared-session') const detachOld = ctx.agents.register(oldOwner) const oldTask = producer({ owner: oldOwner }) ctx.jobs.start(oldTask.spec) - const otherOwner = stubAgent(ctx, 'other-session') - ctx.agents.register(otherOwner) - expect(() => ctx.jobs.start(producer({ owner: otherOwner }).spec)).not.toThrow() + liveAgent(ctx, 'other-session') + expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('other-session') })).not.toThrow() + // The session id resolves to whichever agent is live now; a replacement + // instance starts with its own bucket even while the old job runs on. detachOld() const replacement = stubAgent(ctx, 'shared-session') ctx.agents.register(replacement) @@ -267,8 +308,6 @@ describe('LocalJobRegistry.start', () => { ctx.jobs.start(producer().spec) expect(() => ctx.jobs.start(producer().spec)).toThrow('(limit: 1)') - expect(() => ctx.jobs.start(producer({ owner: oldOwner }).spec)) - .toThrow('is not the registered agent instance') oldTask.settle({ status: 'completed' }) await tick() @@ -285,94 +324,81 @@ describe('LocalJobRegistry.start', () => { }) describe('LocalJobRegistry reads and settlement', () => { - it('stream kinds read a consuming delta; terminal reads mark reported', async () => { + it('read consumes the ring from the model cursor and advances it; readAt never moves it', async () => { const ctx = await harness() - const chunks = ['first', '', 'rest'] - const p = producer({ readOutput: () => chunks.shift() ?? '' }) + const p = producer() const id = ctx.jobs.start(p.spec) + const jobs = jobsOf(ctx) + p.job().append('first', { channel: 'stdout' }) + p.job().append('err', { channel: 'stderr' }) - expect(ctx.jobs.read(id)).toMatchObject({ text: 'first', snapshot: { status: 'running', reported: false } }) - expect(ctx.jobs.read(id).text).toBe('') + const first = jobs.read(id) + expect(first.chunks).toEqual([{ at: 0, text: 'first', channel: 'stdout' }, { at: 5, text: 'err', channel: 'stderr' }]) + expect(first.lossy).toBe(false) + expect(first.result).toBeUndefined() + expect(first.job).toMatchObject({ status: 'running', output: { total: 8, earliest: 0 } }) + expect(jobs.read(id).chunks).toEqual([]) - p.settle({ status: 'completed', detail: 'exit code: 0' }) - await tick() - const read = ctx.jobs.read(id) - expect(read.text).toBe('rest') - expect(read.snapshot).toMatchObject({ status: 'completed', detail: 'exit code: 0', reported: true }) - expect(read.snapshot.finishedAt).toBeTypeOf('number') + // A non-consuming read from the head sees everything again, and leaves the cursor alone. + expect(jobs.readAt(id, 0).chunks).toHaveLength(2) + p.job().append('rest') + expect(jobs.read(id).chunks).toEqual([{ at: 8, text: 'rest' }]) }) - it('projects a producer-owned model output limit into reads and snapshots', async () => { - const ctx = await harness() - const p = producer({ outputLimitBytes: 64, readOutput: () => 'delta' }) - const id = ctx.jobs.start(p.spec) - expect(ctx.jobs.read(id)).toMatchObject({ - text: 'delta', snapshot: { outputLimitBytes: 64 }, - }) - expect(ctx.jobs.get(id)).toMatchObject({ outputLimitBytes: 64 }) - }) - - it('final-output kinds read empty while live, the outcome output idempotently once settled', async () => { + it('hands the producer result out once, on the first read after settlement', async () => { const ctx = await harness() const p = producer({ kind: 'subagent', label: 'research job' }) const id = ctx.jobs.start(p.spec) + const jobs = jobsOf(ctx) + expect(jobs.read(id)).toMatchObject({ chunks: [], job: { status: 'running' } }) - expect(ctx.jobs.read(id)).toMatchObject({ text: '', snapshot: { status: 'running' } }) - - p.settle({ status: 'completed', output: 'final answer' }) + p.settle({ status: 'completed', result: 'final answer' }) await tick() - expect(ctx.jobs.read(id).text).toBe('final answer') - expect(ctx.jobs.read(id).text).toBe('final answer') // idempotent, not consumed + const read = jobs.read(id) + expect(read.result).toBe('final answer') + expect(read.job).toMatchObject({ status: 'completed' }) + expect(read.job.finishedAt).toBeTypeOf('number') + expect(jobs.read(id).result).toBeUndefined() }) - it('a settled job without output reads as empty text', async () => { + it('a settled job without a result reads as empty', async () => { const ctx = await harness() const p = producer({ kind: 'subagent' }) const id = ctx.jobs.start(p.spec) p.settle({ status: 'failed', detail: 'max-tokens' }) await tick() - expect(ctx.jobs.read(id)).toMatchObject({ text: '', snapshot: { status: 'failed', detail: 'max-tokens' } }) + expect(jobsOf(ctx).read(id)).toMatchObject({ chunks: [], job: { status: 'failed', detail: 'max-tokens' } }) + }) + + it('projects a producer-owned model output limit into views', async () => { + const ctx = await harness() + const p = producer({ outputLimitBytes: 64 }) + const id = ctx.jobs.start(p.spec) + expect(jobsOf(ctx).get(id)).toMatchObject({ outputLimitBytes: 64 }) + expect(jobsOf(ctx).read(id).job).toMatchObject({ outputLimitBytes: 64 }) }) it('throws for unknown job ids', async () => { const ctx = await harness() - expect(() => ctx.jobs.read(JobId('bash-99'))).toThrow('unknown job bash-99') + expect(() => jobsOf(ctx).read(JobId('bash-99'))).toThrow('unknown job bash-99') + expect(() => jobsOf(ctx).get(JobId('bash-99'))).toThrow('unknown job bash-99') }) - it('notifies onJobDone once per job with containment across listeners', async () => { + it('announces settlement once with containment across listeners', async () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(() => { throw new Error('listener boom') }) - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + ctx.jobs.events.subscribe({ owners: 'all' }, () => { throw new Error('listener boom') }) + const seen = collect(ctx, { owners: 'all' }, ['settled']) const p = producer() const id = ctx.jobs.start(p.spec) p.settle({ status: 'completed', detail: 'exit code: 0' }) await tick() - expect(seen).toHaveLength(1) - expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false }) + expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id, status: 'completed', detail: 'exit code: 0' }) }]) expect(warn).toHaveBeenCalledWith(expect.stringContaining('listener boom')) }) - it('contains a rejecting onJobDone listener without starving later listeners', async () => { - const ctx = await harness() - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const seen: JobId[] = [] - ctx.jobs.onJobDone(async () => { throw new Error('async listener boom') }) - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot.id)) - - const p = producer() - const id = ctx.jobs.start(p.spec) - p.settle({ status: 'completed' }) - await tick() - - expect(seen).toEqual([id]) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('onJobDone listener rejected')) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('async listener boom')) - }) - it("contains rejection from the producer's done promise as a failed outcome (producer contract violation)", async () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) @@ -381,19 +407,19 @@ describe('LocalJobRegistry reads and settlement', () => { p.reject(new Error('transport exploded')) await tick() - expect(ctx.jobs.read(id).snapshot).toMatchObject({ status: 'failed', detail: 'Error: transport exploded' }) + expect(jobsOf(ctx).get(id)).toMatchObject({ status: 'failed', detail: 'Error: transport exploded' }) expect(warn).toHaveBeenCalledWith(expect.stringContaining('producer contract violation')) }) - it('unregisters onJobDone listeners with the contributing fiber (HMR safety)', async () => { + it('unregisters event listeners with the contributing fiber (HMR safety)', async () => { const ctx = await harness() const seen: string[] = [] const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.jobs.onJobDone(snapshot => void seen.push(snapshot.id)) + inner.jobs.events.subscribe({ owners: 'all' }, event => void seen.push(event.type)) }, { inject: ['jobs'] })) await fiber.dispose() // The returned disposer detaches too (the non-fiber path). - const detach = ctx.jobs.onJobDone(snapshot => void seen.push(snapshot.id)) + const detach = ctx.jobs.events.subscribe({ owners: 'all' }, event => void seen.push(event.type)) detach() const p = producer() @@ -405,22 +431,39 @@ describe('LocalJobRegistry reads and settlement', () => { }) describe('LocalJobRegistry.kill', () => { - it('cancels a live job with the forwarded reason and suppresses the notice', async () => { + it('cancels a live job with the forwarded reason and settles with cause kill', async () => { const ctx = await harness() - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const seen = collect(ctx, { owners: 'all' }, ['stopping', 'settled']) const p = producer() const id = ctx.jobs.start(p.spec) - expect(ctx.jobs.kill(id, undefined, 'no longer needed')).toBe('requested') + expect(jobsOf(ctx).kill(id, { reason: 'no longer needed' })).toBe('requested') expect(p.cancels).toEqual(['no longer needed']) - expect(ctx.jobs.list()[0]).toMatchObject({ status: 'stopping', reported: true }) + expect(jobsOf(ctx).list()[0]).toMatchObject({ status: 'stopping' }) + expect(seen).toEqual([{ type: 'stopping', job: expect.objectContaining({ id, status: 'stopping' }) }]) - p.settle({ status: 'killed' }) + p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) await tick() - // The listener still fires (telemetry may care), but carries reported: true - // so the notice path suppresses its redundant "finished". - expect(seen[0]).toMatchObject({ id, status: 'killed', reported: true }) + expect(seen[1]).toEqual({ + type: 'settled', + cause: 'kill', + job: expect.objectContaining({ id, status: 'killed', detail: 'signal: SIGTERM; no longer needed' }), + }) + }) + + it('merges a reason without producer detail and keeps producer detail alone when the job outran the kill', async () => { + const ctx = await harness() + const killed = producer() + const killedId = ctx.jobs.start(killed.spec) + jobsOf(ctx).kill(killedId, { reason: 'cancelled by the user' }) + killed.settle({ status: 'killed' }) + const outran = producer() + const outranId = ctx.jobs.start(outran.spec) + jobsOf(ctx).kill(outranId, { reason: 'too late' }) + outran.settle({ status: 'completed', detail: 'exit code: 0' }) + await tick() + expect(jobsOf(ctx).get(killedId).detail).toBe('cancelled by the user') + expect(jobsOf(ctx).get(outranId).detail).toBe('exit code: 0') }) it('reports an already-finished job instead of failing', async () => { @@ -429,13 +472,12 @@ describe('LocalJobRegistry.kill', () => { const id = ctx.jobs.start(p.spec) p.settle({ status: 'completed' }) await tick() - expect(ctx.jobs.kill(id)).toBe('already-finished') + expect(jobsOf(ctx).kill(id)).toBe('already-finished') }) it('propagates a throwing producer cancel and leaves the job untouched', async () => { const ctx = await harness() - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const seen = collect(ctx, { owners: 'all' }, ['stopping', 'settled']) let broken = true let settle!: (outcome: JobOutcome) => void const id = ctx.jobs.start({ @@ -446,38 +488,41 @@ describe('LocalJobRegistry.kill', () => { done: new Promise((res) => { settle = res }), }), }) - expect(() => ctx.jobs.kill(id)).toThrow('cancel boom') - // The failed kill mutated NOTHING: still running, notice not suppressed, - // and a later (successful) kill still works. - expect(ctx.jobs.get(id)).toMatchObject({ status: 'running', reported: false }) + expect(() => jobsOf(ctx).kill(id)).toThrow('cancel boom') + // The failed kill mutated NOTHING: still running, and the settlement + // still reports the producer as its cause. + expect(jobsOf(ctx).get(id)).toMatchObject({ status: 'running' }) + expect(seen).toEqual([]) settle({ status: 'completed' }) await tick() - expect(seen[0]).toMatchObject({ id, reported: false }) // notice would still fire + expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id }) }]) broken = false - expect(ctx.jobs.kill(id)).toBe('already-finished') + expect(jobsOf(ctx).kill(id)).toBe('already-finished') }) }) describe('LocalJobRegistry.wait', () => { - it('resolves with the terminal snapshot when the job settles, marked reported', async () => { + it('resolves with the terminal projection when the job settles, before the settled event', async () => { const ctx = await harness() - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const order: string[] = [] + ctx.jobs.events.subscribe({ owners: 'all' }, (event) => { if (event.type === 'settled') order.push('event') }) const p = producer() const id = ctx.jobs.start(p.spec) - const wait = ctx.jobs.wait(id, 5_000) + const wait = jobsOf(ctx).wait(id, 5_000).then((view) => { order.push('wait'); return view }) p.settle({ status: 'completed', detail: 'exit code: 0' }) - expect(await wait).toMatchObject({ status: 'completed', reported: true }) - // A waiting reader claims delivery before completion listeners inspect the snapshot. - expect(seen[0]).toMatchObject({ id, reported: true }) + expect(await wait).toMatchObject({ status: 'completed' }) + // Waiters are released synchronously inside the settlement, ahead of the + // event; their continuations run afterwards, which is why a claim made + // when the wait starts (not when it returns) is what stays ordered. + expect(order).toEqual(['event', 'wait']) }) - it('returns the live snapshot on timeout without marking reported', async () => { + it('returns the live projection on timeout', async () => { const ctx = await harness() const id = ctx.jobs.start(producer().spec) - expect(await ctx.jobs.wait(id, 5)).toMatchObject({ status: 'running', reported: false }) + expect(await jobsOf(ctx).wait(id, 5)).toMatchObject({ status: 'running' }) }) it('unregisters timed-out and aborted wait resolvers while the job remains live', async () => { @@ -485,19 +530,19 @@ describe('LocalJobRegistry.wait', () => { const id = ctx.jobs.start(producer().spec) for (let index = 0; index < 3; index += 1) { - const wait = ctx.jobs.wait(id, 5) + const wait = jobsOf(ctx).wait(id, 5) expect(waitResolverCount(ctx, id)).toBe(1) await expect(wait).resolves.toMatchObject({ status: 'running' }) expect(waitResolverCount(ctx, id)).toBe(0) } const controller = new AbortController() - const wait = ctx.jobs.wait(id, 5_000, undefined, controller.signal) + const wait = jobsOf(ctx).wait(id, 5_000, controller.signal) expect(waitResolverCount(ctx, id)).toBe(1) controller.abort() await expect(wait).rejects.toThrow('wait aborted') expect(waitResolverCount(ctx, id)).toBe(0) - expect(ctx.jobs.get(id).status).toBe('running') + expect(jobsOf(ctx).get(id).status).toBe('running') }) it('returns immediately for an already-finished job', async () => { @@ -506,14 +551,14 @@ describe('LocalJobRegistry.wait', () => { const id = ctx.jobs.start(p.spec) p.settle({ status: 'completed' }) await tick() - expect(await ctx.jobs.wait(id, 5_000)).toMatchObject({ status: 'completed', reported: true }) + expect(await jobsOf(ctx).wait(id, 5_000)).toMatchObject({ status: 'completed' }) }) it('rejects a non-positive or non-finite timeout', async () => { const ctx = await harness() const id = ctx.jobs.start(producer().spec) - await expect(ctx.jobs.wait(id, 0)).rejects.toThrow('invalid wait timeout') - await expect(ctx.jobs.wait(id, Number.NaN)).rejects.toThrow('invalid wait timeout') + await expect(jobsOf(ctx).wait(id, 0)).rejects.toThrow('invalid wait timeout') + await expect(jobsOf(ctx).wait(id, Number.NaN)).rejects.toThrow('invalid wait timeout') }) it('an aborted signal rejects the wait only — the job stays alive', async () => { @@ -521,120 +566,111 @@ describe('LocalJobRegistry.wait', () => { const id = ctx.jobs.start(producer().spec) const controller = new AbortController() - const wait = ctx.jobs.wait(id, 5_000, undefined, controller.signal) + const wait = jobsOf(ctx).wait(id, 5_000, controller.signal) controller.abort() await expect(wait).rejects.toThrow('wait aborted') - expect(ctx.jobs.list()[0]).toMatchObject({ status: 'running' }) + expect(jobsOf(ctx).list()[0]).toMatchObject({ status: 'running' }) const preAborted = new AbortController() preAborted.abort() - await expect(ctx.jobs.wait(id, 5_000, undefined, preAborted.signal)).rejects.toThrow('wait aborted') + await expect(jobsOf(ctx).wait(id, 5_000, preAborted.signal)).rejects.toThrow('wait aborted') }) - it('an abort racing settlement in the same tick does not swallow the notice', async () => { + it('an abort racing settlement in the same tick rejects the wait and leaves the settlement intact', async () => { const ctx = await harness() - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const seen = collect(ctx, { owners: 'all' }, ['settled']) const p = producer() const id = ctx.jobs.start(p.spec) const controller = new AbortController() - const wait = ctx.jobs.wait(id, 5_000, undefined, controller.signal) - // Settlement is queued first, so abort must remove the waiter synchronously; - // otherwise settlement suppresses the notice for a reader that receives nothing. + const wait = jobsOf(ctx).wait(id, 5_000, controller.signal) p.settle({ status: 'completed', detail: 'exit code: 0' }) controller.abort() await expect(wait).rejects.toThrow('wait aborted') - expect(seen).toHaveLength(1) - expect(seen[0]).toMatchObject({ id, status: 'completed', reported: false }) + expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id, status: 'completed' }) }]) }) - it('an abort landing after settlement still delivers the terminal snapshot it owes', async () => { + it('an abort landing after settlement still delivers the terminal projection it owes', async () => { const ctx = await harness() const controller = new AbortController() - const seen: JobSnapshot[] = [] // The listener aborts after settlement released this waiter but before its // resolve microtask runs. Releasing waiters ahead of the announcement is // what makes that abort harmless; this is the guard on that ordering. - ctx.jobs.onJobDone((snapshot) => { - seen.push(snapshot) - controller.abort() - }) + ctx.jobs.events.subscribe({ owners: 'all' }, (event) => { if (event.type === 'settled') controller.abort() }) const p = producer() const id = ctx.jobs.start(p.spec) - const wait = ctx.jobs.wait(id, 5_000, undefined, controller.signal) + const wait = jobsOf(ctx).wait(id, 5_000, controller.signal) p.settle({ status: 'completed', detail: 'exit code: 0' }) - await expect(wait).resolves.toMatchObject({ status: 'completed', reported: true }) - expect(seen[0]).toMatchObject({ id, reported: true }) // suppression stays honest: the wait delivered + await expect(wait).resolves.toMatchObject({ status: 'completed' }) }) }) describe('LocalJobRegistry owner isolation', () => { - it('fences read/kill/wait to the owning session and keeps unowned jobs open', async () => { + it('fences every view operation to the owning session and keeps unowned jobs open', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'owner') const other = stubAgent(ctx, 'other') const owned = ctx.jobs.start(producer({ owner }).spec) const open = ctx.jobs.start(producer().spec) // The owner and the unowned job are reachable. - expect(ctx.jobs.read(owned, owner).snapshot.id).toBe(owned) - expect(ctx.jobs.read(open, other).snapshot.id).toBe(open) + expect(jobsOf(ctx, owner).read(owned).job.id).toBe(owned) + expect(jobsOf(ctx, other).read(open).job.id).toBe(open) - // A different session and a no-agent caller are rejected. - expect(() => ctx.jobs.read(owned, other)).toThrow(`job ${owned} belongs to another session`) - expect(() => ctx.jobs.kill(owned, other)).toThrow('belongs to another session') - await expect(ctx.jobs.wait(owned, 10, other)).rejects.toThrow('belongs to another session') - expect(() => ctx.jobs.read(owned)).toThrow('belongs to another session') + // A different session and an anonymous caller are rejected. + const foreign = jobsOf(ctx, other) + expect(() => foreign.read(owned)).toThrow(`job ${owned} belongs to another session`) + expect(() => foreign.get(owned)).toThrow('belongs to another session') + expect(() => foreign.readAt(owned, 0)).toThrow('belongs to another session') + expect(() => foreign.kill(owned)).toThrow('belongs to another session') + await expect(foreign.wait(owned, 10)).rejects.toThrow('belongs to another session') + expect(() => jobsOf(ctx).read(owned)).toThrow('belongs to another session') }) it('list() shows only caller-owned plus unowned jobs', async () => { const ctx = await harness() - const alice = stubAgent(ctx, 'alice') - const bob = stubAgent(ctx, 'bob') - ctx.agents.register(alice) - ctx.agents.register(bob) + const alice = liveAgent(ctx, 'alice') + const bob = liveAgent(ctx, 'bob') const aliceTask = ctx.jobs.start(producer({ owner: alice }).spec) const bobTask = ctx.jobs.start(producer({ owner: bob }).spec) const openTask = ctx.jobs.start(producer({ kind: 'subagent' }).spec) - expect(ctx.jobs.list(alice).map(t => t.id)).toEqual([aliceTask, openTask]) - expect(ctx.jobs.list(bob).map(t => t.id)).toEqual([bobTask, openTask]) - expect(ctx.jobs.list().map(t => t.id)).toEqual([openTask]) + expect(jobsOf(ctx, alice).list().map(t => t.id)).toEqual([aliceTask, openTask]) + expect(jobsOf(ctx, bob).list().map(t => t.id)).toEqual([bobTask, openTask]) + expect(jobsOf(ctx).list().map(t => t.id)).toEqual([openTask]) }) it('rejects an owned registration when no agent registry is mounted', async () => { const ctx = new Context() await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') - expect(() => ctx.jobs.start(producer({ owner: stubAgent(ctx, 'a') }).spec)) + expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('a') })) .toThrow('background job ownership requires the agent registry') // The failed registration mutated nothing: no stored job, counter untouched. - expect(ctx.jobs.list()).toEqual([]) + expect(jobsOf(ctx).list()).toEqual([]) expect(ctx.jobs.start(producer().spec)).toBe('bash-1') }) - it('a failed owner-cleanup attach leaves the registry unchanged and does not poison the owner', async () => { + it('rejects a session without a live agent and attaches cleanup once it has one', async () => { const ctx = await harness() const ghost = stubAgent(ctx, 'ghost') // never registered in ctx.agents - // Exact-instance validation precedes registry mutation and cleanup attachment. + // Owner resolution precedes registry mutation and cleanup attachment. expect(() => ctx.jobs.start(producer({ owner: ghost }).spec)) - .toThrow('is not the registered agent instance') - expect(ctx.jobs.list(ghost)).toEqual([]) + .toThrow('has no live agent') + expect(jobsOf(ctx, ghost).list()).toEqual([]) - // A later valid registration must still attach cleanup for the same object. + // A later valid registration must still attach cleanup for the live instance. ctx.agents.register(ghost) const cancels: (string | undefined)[] = [] let settle!: (outcome: JobOutcome) => void const id = ctx.jobs.start({ kind: 'bash', label: 'after retry', - owner: ghost, + owner: ghost.id, run: () => ({ cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) }, done: new Promise((res) => { settle = res }), @@ -643,42 +679,37 @@ describe('LocalJobRegistry owner isolation', () => { expect(id).toBe('bash-1') // the failed attempt burned no counter await disposeAgentScope(ghost) expect(cancels).toEqual(['owner disposed']) - expect(ctx.jobs.list(ghost)).toEqual([]) + expect(jobsOf(ctx, ghost).list()).toEqual([]) }) - it('rejects a stale owner instance after another agent reuses its id', async () => { + it('binds a session id to the agent that is live now, and reads stay keyed by that id', async () => { const ctx = await harness() const staleOwner = stubAgent(ctx, 'owner') const unregisterStale = ctx.agents.register(staleOwner) unregisterStale() - const currentOwner = stubAgent(ctx, 'owner') ctx.agents.register(currentOwner) - const current = producer({ owner: currentOwner }) - ctx.jobs.start(current.spec) // Attach the current owner's cleanup first. - const stale = producer({ owner: staleOwner }) - const staleRun = vi.fn((job: RecordingJob) => stale.spec.run(job)) - expect(() => ctx.jobs.start({ ...stale.spec, run: staleRun })) - .toThrow('is not the registered agent instance') - expect(staleRun).not.toHaveBeenCalled() - // Access is keyed by the unified session id, so a reconnect carrying the - // same identity can observe the current job even though stale ownership - // registration is rejected by exact-instance validation. - expect(ctx.jobs.list(staleOwner)).toHaveLength(1) - expect(ctx.jobs.list(currentOwner)).toHaveLength(1) + const current = producer({ owner: currentOwner }) + const id = ctx.jobs.start(current.spec) + // The stale instance shares the id, so the fence lets it read; ownership + // (and therefore teardown) belongs to the live instance. + expect(jobsOf(ctx, staleOwner).get(id).owner).toBe(currentOwner.id) + await disposeAgentScope(staleOwner) + expect(jobsOf(ctx, currentOwner).list()).toHaveLength(1) current.settle({ status: 'completed' }) await tick() await disposeAgentScope(currentOwner) + expect(jobsOf(ctx, currentOwner).list()).toEqual([]) }) }) describe('LocalJobRegistry owner cleanup', () => { - it('drains the owner: cancels live jobs, awaits settlement, drops snapshots', async () => { + it('drains the owner: cancels live jobs, awaits settlement, removes the rows', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'owner') + const seen = collect(ctx, { owner: owner.id }, ['removed']) // The producer settles only when cancelled — models a child that stops on request. let settle!: (outcome: JobOutcome) => void @@ -686,7 +717,7 @@ describe('LocalJobRegistry owner cleanup', () => { ctx.jobs.start({ kind: 'subagent', label: 'long research', - owner, + owner: owner.id, run: () => ({ cancel(reason) { cancels.push(reason); settle({ status: 'killed' }) }, done: new Promise((res) => { settle = res }), @@ -699,59 +730,33 @@ describe('LocalJobRegistry owner cleanup', () => { await disposeAgentScope(owner) expect(cancels).toEqual(['owner disposed']) - // Snapshots dropped: nothing of the owner's remains, listing is empty. - expect(ctx.jobs.list(owner)).toEqual([]) + expect(jobsOf(ctx, owner).list()).toEqual([]) + expect(seen.map(event => event.type === 'removed' ? event.job.id : undefined)).toEqual(['subagent-1', 'bash-1']) }) - it('publishes the settled visible set before announcing completion', async () => { + it('announces teardown settlements with cause teardown', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const p = producer({ owner }) - ctx.jobs.start(p.spec) - // Registered after start so only the settlement's notifications are ordered. - const order: string[] = [] - ctx.jobs.onJobsChanged(() => void order.push('changed')) - ctx.jobs.onJobDone(() => void order.push('done')) - - p.settle({ status: 'completed' }) - await tick() - - // A completion reporter may open a turn synchronously. Announcing before - // the visible set is published would let a client render that turn while - // its job row still reads `running`. - expect(order).toEqual(['changed', 'done']) - }) - - it('reports a teardown-cancelled record so completion reporters stay quiet', async () => { - const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const owner = liveAgent(ctx, 'owner') + const seen = collect(ctx, { owners: 'all' }, ['settled']) let settle!: (outcome: JobOutcome) => void ctx.jobs.start({ kind: 'subagent', label: 'long research', - owner, + owner: owner.id, run: () => ({ cancel() { settle({ status: 'killed' }) }, done: new Promise((res) => { settle = res }), }), }) - // Observers still receive the terminal record; the report bit is what - // keeps a notice reporter from addressing an owner being destroyed. await disposeAgentScope(owner) - expect(seen).toHaveLength(1) - expect(seen[0]?.reported).toBe(true) + expect(seen).toEqual([{ type: 'settled', cause: 'teardown', job: expect.objectContaining({ status: 'killed' }) }]) }) it('attaches one cleanup per owner and drains all owned jobs with the scope', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'owner') const first = producer({ owner }) const second = producer({ owner }) @@ -762,21 +767,21 @@ describe('LocalJobRegistry owner cleanup', () => { await tick() expect(owner.ctx.fiber.getEffects().filter(effect => effect.label === 'jobs.ownerCleanup()')).toHaveLength(1) await disposeAgentScope(owner) - expect(ctx.jobs.list(owner)).toEqual([]) + expect(jobsOf(ctx, owner).list()).toEqual([]) }) - it('does not let an old scope cleanup cancel a same-id/session replacement job', async () => { + it('does not let an old scope cleanup cancel a same-session replacement job', async () => { const ctx = await harness() const oldOwner = stubAgent(ctx, 'owner') const detachOld = ctx.agents.register(oldOwner) const cancels: string[] = [] - function start(owner: Agent, label: string): JobId { + function start(label: string): JobId { let settle!: (outcome: JobOutcome) => void return ctx.jobs.start({ kind: 'bash', label, - owner, + owner: SessionId('owner'), run: () => ({ cancel() { cancels.push(label); settle({ status: 'killed' }) }, done: new Promise((resolve) => { settle = resolve }), @@ -784,15 +789,15 @@ describe('LocalJobRegistry owner cleanup', () => { }) } - start(oldOwner, 'old job') + start('old job') detachOld() const replacement = stubAgent(ctx, 'owner') ctx.agents.register(replacement) - const replacementId = start(replacement, 'replacement job') + const replacementId = start('replacement job') await disposeAgentScope(oldOwner) expect(cancels).toEqual(['old job']) - expect(ctx.jobs.list(replacement).map(job => job.id)).toEqual([replacementId]) + expect(jobsOf(ctx, replacement).list().map(job => job.id)).toEqual([replacementId]) await disposeAgentScope(replacement) expect(cancels).toEqual(['old job', 'replacement job']) @@ -803,8 +808,7 @@ describe('LocalJobRegistry owner cleanup', () => { await ctx.plugin(AgentRegistry) const tasksFiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'owner') const ownerCleanupEffects = () => owner.ctx.fiber.getEffects() .filter(effect => effect.label === 'jobs.ownerCleanup()') @@ -821,22 +825,19 @@ describe('LocalJobRegistry owner cleanup', () => { expect(ownerCleanupEffects()).toHaveLength(0) expect(ctx.get('jobs')).toBeDefined() expect(tasksFiber.getEffects().some(effect => effect.label === 'jobs teardown')).toBe(true) - }) it('force-fails a throwing teardown cancel without awaiting producer done, first outcome wins', async () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const owner = liveAgent(ctx, 'owner') + const seen = collect(ctx, { owners: 'all' }, ['settled']) let settle!: (outcome: JobOutcome) => void ctx.jobs.start({ kind: 'bash', label: 'broken producer', - owner, + owner: owner.id, run: () => ({ cancel() { throw new Error('cancel boom') }, done: new Promise((res) => { settle = res }), @@ -861,24 +862,27 @@ describe('LocalJobRegistry owner cleanup', () => { expect(drainedWithoutProducerDone).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned')) expect(seen).toHaveLength(1) - expect(seen[0]?.status).toBe('failed') - expect(seen[0]?.detail).toContain('cancel threw during teardown') - expect(ctx.jobs.list(owner)).toEqual([]) + expect(seen[0]).toMatchObject({ cause: 'teardown', job: { status: 'failed' } }) + expect((seen[0] as { job: JobView }).job.detail).toContain('cancel threw during teardown') + expect(jobsOf(ctx, owner).list()).toEqual([]) }) }) describe('LocalJobRegistry disposal', () => { - it('cancels live jobs, awaits settlement, and silences listeners', async () => { + it('cancels live jobs, awaits settlement, and announces the teardown to outside subscribers', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(LocalJobRegistry) - const controller = await ctx.plugin(Object.assign((inner: Context) => { + await ctx.plugin(Object.assign((inner: Context) => { inner.jobs.attachController('test-controller') }, { inject: ['jobs'] })) - void controller + // A subscriber outside the registry's fiber is still listening when the + // registry unloads: it must see the rows leave, not keep them forever. const seen: string[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot.id)) + ctx.jobs.events.subscribe({ owners: 'all' }, (event) => { + seen.push(event.type === 'settled' ? `settled:${event.cause}` : event.type) + }) let settle!: (outcome: JobOutcome) => void const cancels: (string | undefined)[] = [] ctx.jobs.start({ @@ -892,8 +896,7 @@ describe('LocalJobRegistry disposal', () => { await fiber.dispose() expect(cancels).toEqual(['jobs service disposed']) - // The teardown kill settles AFTER the listener registry closed: silent. - expect(seen).toEqual([]) + expect(seen).toEqual(['registered', 'stopping', 'settled:teardown', 'output', 'removed']) }) it('force-fails a throwing cancel so service disposal does not await producer done', async () => { @@ -902,8 +905,7 @@ describe('LocalJobRegistry disposal', () => { const fiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const seen: JobSnapshot[] = [] - ctx.jobs.onJobDone(snapshot => void seen.push(snapshot)) + const seen = collect(ctx, { owners: 'all' }, ['settled']) let settle!: (outcome: JobOutcome) => void ctx.jobs.start({ @@ -931,7 +933,7 @@ describe('LocalJobRegistry disposal', () => { expect(disposedWithoutProducerDone).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned')) - expect(seen).toEqual([]) + expect(seen).toEqual([{ type: 'settled', cause: 'teardown', job: expect.objectContaining({ status: 'failed' }) }]) }) it('detaches owner effects from still-live agent scopes when the service unloads', async () => { @@ -939,13 +941,12 @@ describe('LocalJobRegistry disposal', () => { await ctx.plugin(AgentRegistry) const tasksFiber = await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('test-controller') - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'owner') let settle!: (outcome: JobOutcome) => void ctx.jobs.start({ kind: 'bash', label: 'owned work', - owner, + owner: owner.id, run: () => ({ cancel() { settle({ status: 'killed' }) }, done: new Promise((resolve) => { settle = resolve }), @@ -971,11 +972,10 @@ describe('LocalJobRegistry disposal', () => { inject: ['jobs'], apply(pluginCtx: Context) { pluginCtx.jobs.attachController('tool-jobs') - pluginCtx.jobs.onJobDone(() => {}) + pluginCtx.jobs.events.subscribe({ owners: 'scope' }, () => {}) }, }) - const owner = stubAgent(ctx, 'joined', scopeOf(standing.ctx)) - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'joined', scopeOf(standing.ctx)) expect(() => ctx.jobs.start(producer({ owner }).spec)).not.toThrow() await mount.dispose() @@ -1003,198 +1003,162 @@ describe('LocalJobRegistry disposal', () => { }) }) -describe('LocalJobRegistry.onJobsChanged', () => { - it('fires after registration, the stopping transition, and settlement', async () => { +describe('LocalJobRegistry events', () => { + it('announces registration, progress, stopping, and settlement with the committed projection', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'alice') - ctx.agents.register(owner) - const seen: (string | undefined)[] = [] - ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id)) + const owner = liveAgent(ctx, 'alice') + const seen = collect(ctx, { owner: owner.id }, ['registered', 'progress', 'stopping', 'settled']) const p = producer({ owner }) const id = ctx.jobs.start(p.spec) // Registration is announced only once the record is readable. - expect(seen).toEqual(['alice']) - expect(ctx.jobs.list(owner)).toHaveLength(1) + expect(seen).toEqual([{ type: 'registered', job: expect.objectContaining({ id, owner: 'alice', status: 'running' }) }]) + expect(jobsOf(ctx, owner).list()).toHaveLength(1) - expect(ctx.jobs.kill(id, owner)).toBe('requested') - expect(seen).toEqual(['alice', 'alice']) - expect(ctx.jobs.get(id, owner).status).toBe('stopping') + p.job().updateProgress('3/10') + expect(seen[1]).toEqual({ type: 'progress', job: expect.objectContaining({ id, progress: '3/10' }) }) + + expect(jobsOf(ctx, owner).kill(id)).toBe('requested') + expect(seen[2]).toEqual({ type: 'stopping', job: expect.objectContaining({ id, status: 'stopping', progress: '3/10' }) }) p.settle({ status: 'killed' }) await tick() - expect(seen).toEqual(['alice', 'alice', 'alice']) - expect(ctx.jobs.get(id, owner).status).toBe('killed') + const settled = seen[3] as { job: JobView } + expect(seen[3]).toMatchObject({ type: 'settled', cause: 'kill', job: { id, status: 'killed' } }) + expect(settled.job.progress).toBeUndefined() await disposeAgentScope(owner) }) - it('reports an unowned change as undefined, since every caller can see it', async () => { + it('announces an unowned job to every session filter, since every caller can see it', async () => { const ctx = await harness() - const seen: (string | undefined)[] = [] - ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id)) - + const seen = collect(ctx, { owner: SessionId('anyone') }, ['registered']) ctx.jobs.start(producer().spec) - expect(seen).toEqual([undefined]) + expect(seen).toEqual([{ type: 'registered', job: expect.objectContaining({ id: 'bash-1' }) }]) + expect((seen[0] as { job: JobView }).job.owner).toBeUndefined() }) it('announces the owner-disposal removal, and stays silent when that owner had none', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'alice') - const bystander = stubAgent(ctx, 'bob') - ctx.agents.register(owner) - ctx.agents.register(bystander) + const owner = liveAgent(ctx, 'alice') + const bystander = liveAgent(ctx, 'bob') const p = producer({ owner }) ctx.jobs.start(p.spec) - const seen: (string | undefined)[] = [] - ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id)) + const seen = collect(ctx, { owners: 'all' }, ['settled', 'removed']) p.settle({ status: 'completed' }) await tick() - expect(seen).toEqual(['alice']) + expect(seen.map(event => event.type)).toEqual(['settled']) // Disposing an owner with no records changes no visible set. await disposeAgentScope(bystander) - expect(seen).toEqual(['alice']) + expect(seen.map(event => event.type)).toEqual(['settled']) await disposeAgentScope(owner) - expect(seen).toEqual(['alice', 'alice']) - expect(ctx.jobs.list(owner)).toEqual([]) + expect(seen.map(event => event.type)).toEqual(['settled', 'removed']) + expect(jobsOf(ctx, owner).list()).toEqual([]) }) it('contains a throwing listener so the lifecycle commit still stands', async () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const seen: (string | undefined)[] = [] - ctx.jobs.onJobsChanged(() => { throw new Error('observer boom') }) - ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id)) + ctx.jobs.events.subscribe({ owners: 'all' }, () => { throw new Error('observer boom') }) + const seen = collect(ctx, { owners: 'all' }, ['registered']) const id = ctx.jobs.start(producer().spec) expect(id).toBe('bash-1') - expect(seen).toEqual([undefined]) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('onJobsChanged listener threw')) + expect(seen).toHaveLength(1) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('event listener threw on registered')) }) - it('unregisters through its disposer and with its fiber (HMR safety)', async () => { - const ctx = await harness() - const seen: number[] = [] - const detach = ctx.jobs.onJobsChanged(() => void seen.push(1)) - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.jobs.onJobsChanged(() => void seen.push(2)) - }, { inject: ['jobs'] })) - - ctx.jobs.start(producer().spec) - expect(seen).toEqual([1, 2]) - - detach() - detach() // second call of the same disposer is a no-op - ctx.jobs.start(producer().spec) - expect(seen).toEqual([1, 2, 2]) - - await fiber.dispose() - ctx.jobs.start(producer().spec) - expect(seen).toEqual([1, 2, 2]) - }) -}) - -describe('LocalJobRegistry teardown change notifications', () => { it('announces the stopping transition during owner teardown, before settlement', async () => { const ctx = await harness() - const owner = stubAgent(ctx, 'alice') - ctx.agents.register(owner) + const owner = liveAgent(ctx, 'alice') const p = producer({ owner }) - const id = ctx.jobs.start(p.spec) + ctx.jobs.start(p.spec) - const statuses: (string | undefined)[] = [] - ctx.jobs.onJobsChanged((changed) => { - statuses.push(changed === undefined ? undefined : ctx.jobs.list(changed)[0]?.status) + const statuses: string[] = [] + ctx.jobs.events.subscribe({ owner: owner.id }, (event) => { + if (event.type !== 'output') statuses.push(`${event.type}:${event.job.status}`) }) // A slow producer keeps teardown parked between cancel and settlement; // an observer must not be left showing `running` for that whole window. const disposal = disposeAgentScope(owner) await tick() - expect(statuses).toEqual(['stopping']) + expect(statuses).toEqual(['stopping:stopping']) p.settle({ status: 'killed' }) await disposal // Settlement, then the removal that empties the visible set. - expect(statuses).toEqual(['stopping', 'killed', undefined]) - expect(ctx.jobs.list(owner)).toEqual([]) - void id + expect(statuses).toEqual(['stopping:stopping', 'settled:killed', 'removed:killed']) + expect(jobsOf(ctx, owner).list()).toEqual([]) }) - it('announces the emptied set to a listener registered outside this service (reload safety)', async () => { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - const fiber = await ctx.plugin(LocalJobRegistry) - ctx.jobs.attachController('test-controller') - - // The api-proxy carrier registers from its own stream context, not the - // registry's fiber, so it is still listening when the registry unloads. - const seen: (string | undefined)[] = [] - ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id)) - let settle!: (outcome: JobOutcome) => void - ctx.jobs.start({ - kind: 'bash', - label: 'sleep 600', - run: () => ({ - cancel() { settle({ status: 'killed' }) }, - done: new Promise((resolve) => { settle = resolve }), - }), + it('delivers scoped subscriptions to the owning composition only', async () => { + const ctx = await harness() + const mount = createScope(ctx, {}) + const otherMount = createScope(ctx, {}) + const alice = liveAgent(ctx, 'alice', scopeOf(mount.ctx)) + const bob = liveAgent(ctx, 'bob', scopeOf(otherMount.ctx)) + const everyone = collect(ctx, { owners: 'all' }, ['output']) + const scoped: string[] = [] + await mount.ctx.plugin({ + inject: ['jobs'], + apply(scopedCtx: Context) { + scopedCtx.jobs.events.subscribe({ owners: 'scope' }, (event) => { + if (event.type === 'output') scoped.push(String(event.id)) + }) + }, }) - seen.length = 0 - - await fiber.dispose() - // stopping (teardown cancel), settlement, then the final empty set. - expect(seen).toEqual([undefined, undefined, undefined]) + const pa = producer({ owner: alice }) + const aliceId = ctx.jobs.start(pa.spec) + const pb = producer({ owner: bob }) + ctx.jobs.start(pb.spec) + pa.job().append('for alice observers') + pb.job().append('for bob observers') + expect(everyone).toHaveLength(2) + expect(scoped).toEqual([String(aliceId)]) }) }) -describe('LocalJobRegistry record', () => { - it('appends advance absolute offsets; record reads are non-consuming and never mark reported', async () => { +describe('LocalJobRegistry output ring', () => { + it('appends advance absolute offsets and reach observers as output events', async () => { const ctx = await harness() - const p = producer({ record: true }) + const outputs = collect(ctx, { owners: 'all' }, ['output']) + const p = producer() const id = ctx.jobs.start(p.spec) p.job().append('hello ') p.job().append('world', { channel: 'stderr' }) - const first = ctx.jobs.readRecord(id, 0) - expect(first.lossy).toBe(false) - expect(first.next).toBe(11) - expect(first.chunks).toEqual([ - { at: 0, text: 'hello ' }, - { at: 6, text: 'world', channel: 'stderr' }, - ]) + const first = jobsOf(ctx).readAt(id, 0) + expect(first).toEqual({ + chunks: [{ at: 0, text: 'hello ' }, { at: 6, text: 'world', channel: 'stderr' }], + next: 11, + lossy: false, + }) // A second identical read proves nothing was consumed. - expect(ctx.jobs.readRecord(id, 0)).toEqual(first) + expect(jobsOf(ctx).readAt(id, 0)).toEqual(first) // Resuming from `next` yields nothing until more output arrives. - expect(ctx.jobs.readRecord(id, first.next).chunks).toEqual([]) + expect(jobsOf(ctx).readAt(id, first.next).chunks).toEqual([]) p.job().append('!') - expect(ctx.jobs.readRecord(id, first.next).chunks).toEqual([{ at: 11, text: '!' }]) - const snapshot = ctx.jobs.get(id) - expect(snapshot.outputTotal).toBe(12) - expect(snapshot.outputEarliest).toBe(0) - // The observer surface owns no notice state: even a terminal record read - // leaves the completion notice due. - p.settle({ status: 'completed' }) - await tick() - ctx.jobs.readRecord(id, 0) - expect(ctx.jobs.get(id).reported).toBe(false) + expect(jobsOf(ctx).readAt(id, first.next).chunks).toEqual([{ at: 11, text: '!' }]) + expect(jobsOf(ctx).get(id).output).toEqual({ total: 12, earliest: 0 }) + expect(outputs).toEqual([ + { type: 'output', id, total: 6 }, + { type: 'output', id, total: 11 }, + { type: 'output', id, total: 12 }, + ]) }) it('staged starter writes surface at the registration commit without observer signals', async () => { const ctx = await harness() - const outputSignals: string[] = [] - ctx.jobs.onOutput((id) => { outputSignals.push(String(id)) }) - const changes: number[] = [] - ctx.jobs.onJobsChanged(() => { changes.push(1) }) + const seen = collect(ctx) let settle!: (outcome: JobOutcome) => void const id = ctx.jobs.start({ kind: 'bash', label: 'staged writes', - record: true, run: (job) => { job.append('early ', { channel: 'stdout' }) - job.updateDetail('booting') + job.updateProgress('booting') return { cancel() {}, done: new Promise((resolve) => { settle = resolve }), @@ -1202,253 +1166,170 @@ describe('LocalJobRegistry record', () => { }, }) // Only the registration commit announced anything. - expect(outputSignals).toEqual([]) - expect(changes).toEqual([1]) - const snapshot = ctx.jobs.get(id) - expect(snapshot.detail).toBe('booting') - expect(snapshot.outputTotal).toBe(6) - expect(ctx.jobs.readRecord(id, 0).chunks).toEqual([{ at: 0, text: 'early ', channel: 'stdout' }]) + expect(seen.map(event => event.type)).toEqual(['registered']) + const view = jobsOf(ctx).get(id) + expect(view.progress).toBe('booting') + expect(view.output.total).toBe(6) + expect(jobsOf(ctx).readAt(id, 0).chunks).toEqual([{ at: 0, text: 'early ', channel: 'stdout' }]) settle({ status: 'completed' }) await tick() }) it('drops an empty append without waking observers', async () => { const ctx = await harness() - const signals: string[] = [] - ctx.jobs.onOutput((id) => { signals.push(String(id)) }) - const p = producer({ record: true }) + const outputs = collect(ctx, { owners: 'all' }, ['output']) + const p = producer() const id = ctx.jobs.start(p.spec) p.job().append('') - expect(signals).toEqual([]) - expect(ctx.jobs.get(id).outputTotal).toBe(0) + expect(outputs).toEqual([]) + expect(jobsOf(ctx).get(id).output.total).toBe(0) }) - it('hands a start without a record declaration no append; updateDetail still works', async () => { + it('rejects a negative or fractional read offset', async () => { const ctx = await harness() const p = producer() const id = ctx.jobs.start(p.spec) - // The plain face is built without the method, not with a dropping stub. - expect(p.job()).not.toHaveProperty('append') - expect(() => ctx.jobs.readRecord(id, 0)).toThrow(`job ${id} declared no output record`) - const snapshot = ctx.jobs.get(id) - expect(snapshot.outputTotal).toBeUndefined() - expect(snapshot.outputEarliest).toBeUndefined() - p.job().updateDetail('7/10 items') - expect(ctx.jobs.get(id).detail).toBe('7/10 items') - }) - - it('rejects a negative or fractional record read offset', async () => { - const ctx = await harness() - const p = producer({ record: true }) - const id = ctx.jobs.start(p.spec) - expect(() => ctx.jobs.readRecord(id, -1)).toThrow(/invalid record read offset/) - expect(() => ctx.jobs.readRecord(id, 0.5)).toThrow(/invalid record read offset/) - expect(() => ctx.jobs.readRecord(JobId('bash-99'), 0)).toThrow(/unknown job/) - }) - - it('fences record reads to the owning session', async () => { - const ctx = await harness() - const alice = stubAgent(ctx, 'alice') - const bob = stubAgent(ctx, 'bob') - ctx.agents.register(alice) - ctx.agents.register(bob) - const p = producer({ owner: alice, record: true }) - const id = ctx.jobs.start(p.spec) - p.job().append('secret') - expect(() => ctx.jobs.readRecord(id, 0, bob)).toThrow(/belongs to another session/) - expect(() => ctx.jobs.readRecord(id, 0)).toThrow(/belongs to another session/) - expect(ctx.jobs.readRecord(id, 0, alice).chunks[0]?.text).toBe('secret') + expect(() => jobsOf(ctx).readAt(id, -1)).toThrow(/invalid output read offset/) + expect(() => jobsOf(ctx).readAt(id, 0.5)).toThrow(/invalid output read offset/) + expect(() => jobsOf(ctx).readAt(JobId('bash-99'), 0)).toThrow(/unknown job/) }) it('evicts whole head chunks past the live cap and flags stale readers lossy', async () => { const ctx = await harness({ retainBytes: 8 }) - const p = producer({ record: true }) + const p = producer() const id = ctx.jobs.start(p.spec) p.job().append('aaaa') p.job().append('bbbb') p.job().append('cccc') - const read = ctx.jobs.readRecord(id, 0) + const read = jobsOf(ctx).readAt(id, 0) expect(read.lossy).toBe(true) expect(read.chunks).toEqual([{ at: 4, text: 'bbbb' }, { at: 8, text: 'cccc' }]) expect(read.next).toBe(12) - const snapshot = ctx.jobs.get(id) - expect(snapshot.outputEarliest).toBe(4) - expect(snapshot.outputTotal).toBe(12) + expect(jobsOf(ctx).get(id).output).toEqual({ total: 12, earliest: 4 }) // A reader at the retained boundary is not lossy. - expect(ctx.jobs.readRecord(id, 4).lossy).toBe(false) + expect(jobsOf(ctx).readAt(id, 4).lossy).toBe(false) + // The model cursor is subject to the same eviction. + expect(jobsOf(ctx).read(id).lossy).toBe(true) }) - it('keeps only the UTF-8-safe tail of a single oversized chunk', async () => { - const ctx = await harness({ retainBytes: 5 }) - const p = producer({ record: true }) - const id = ctx.jobs.start(p.spec) - // '你好' is six UTF-8 bytes; a five-byte budget must not split the second code point. - p.job().append('你好') - const read = ctx.jobs.readRecord(id, 0) - expect(read.lossy).toBe(true) - expect(read.chunks).toEqual([{ at: 3, text: '好', gapBefore: true }]) - const snapshot = ctx.jobs.get(id) - expect(snapshot.outputEarliest).toBe(3) - expect(snapshot.outputTotal).toBe(6) - }) - - it('an exhausted byte budget leaves an empty gap-marked tail at the total offset', async () => { - const ctx = await harness({ retainBytes: 1 }) - const p = producer({ record: true }) - const id = ctx.jobs.start(p.spec) - // Every byte of '好' from the cut is a continuation byte, so no code point survives. - p.job().append('好') - const read = ctx.jobs.readRecord(id, 0) - expect(read.lossy).toBe(true) - expect(read.chunks).toEqual([{ at: 3, text: '', gapBefore: true }]) - expect(ctx.jobs.get(id).outputEarliest).toBe(3) - }) - - it('returns the whole overlapped chunk for a foreign mid-chunk offset', async () => { - const ctx = await harness() - const p = producer({ record: true }) - const id = ctx.jobs.start(p.spec) - p.job().append('abcdef') - const read = ctx.jobs.readRecord(id, 3) - expect(read.chunks).toEqual([{ at: 0, text: 'abcdef' }]) - expect(read.lossy).toBe(false) - }) - - it('preserves a producer-reported gap marker through reads', async () => { - const ctx = await harness() - const p = producer({ record: true }) - const id = ctx.jobs.start(p.spec) - p.job().append('tail after drop', { gapBefore: true, channel: 'stdout' }) - expect(ctx.jobs.readRecord(id, 0).chunks).toEqual([ - { at: 0, text: 'tail after drop', channel: 'stdout', gapBefore: true }, - ]) - }) - - it('settlement trims to the settled cap, signals once more, and drops later writes', async () => { + it('settlement trims to the settled cap, ends the stream, and drops later writes', async () => { const ctx = await harness({ retainBytes: 1024, settledRetainBytes: 4 }) const warn = vi.fn() ctx.logger.warn = warn as never - const signals: string[] = [] - ctx.jobs.onOutput((id) => { signals.push(String(id)) }) - const p = producer({ record: true }) + const outputs = collect(ctx, { owners: 'all' }, ['output']) + const p = producer() const id = ctx.jobs.start(p.spec) p.job().append('abcdefgh') p.settle({ status: 'completed', detail: 'exit code: 0' }) await tick() - expect(signals).toEqual([String(id), String(id)]) - const snapshot = ctx.jobs.get(id) - expect(snapshot).toMatchObject({ status: 'completed', detail: 'exit code: 0', outputTotal: 8, outputEarliest: 4 }) - const read = ctx.jobs.readRecord(id, 0) + expect(outputs.map(event => event.type === 'output' ? event.total : -1)).toEqual([8, 8]) + expect(jobsOf(ctx).get(id)).toMatchObject({ status: 'completed', detail: 'exit code: 0', output: { total: 8, earliest: 4 } }) + const read = jobsOf(ctx).readAt(id, 0) expect(read.lossy).toBe(true) expect(read.chunks).toEqual([{ at: 4, text: 'efgh', gapBefore: true }]) // The stream ended with the settlement: a trailing flush cannot break teardown. p.job().append('late') - p.job().updateDetail('late detail') + p.job().updateProgress('late progress') expect(warn.mock.calls.map(call => String(call[0]))).toEqual([ expect.stringContaining('append to settled job'), - expect.stringContaining('detail update on settled job'), + expect.stringContaining('progress update on settled job'), ]) - expect(signals).toHaveLength(2) - expect(ctx.jobs.get(id).detail).toBe('exit code: 0') + expect(outputs).toHaveLength(2) + expect(jobsOf(ctx).get(id).progress).toBeUndefined() }) - it('a terminal outcome without detail keeps the last progress line', async () => { + it('settlement clears the progress line; the terminal detail is the outcome alone', async () => { const ctx = await harness() - const changes: number[] = [] - const p = producer({ record: true }) + const p = producer() const id = ctx.jobs.start(p.spec) - ctx.jobs.onJobsChanged(() => { changes.push(1) }) - p.job().updateDetail('3/10 agents done') - expect(changes).toHaveLength(1) - expect(ctx.jobs.get(id).detail).toBe('3/10 agents done') + p.job().updateProgress('3/10 agents done') + expect(jobsOf(ctx).get(id)).toMatchObject({ progress: '3/10 agents done' }) p.settle({ status: 'completed' }) await tick() - expect(ctx.jobs.get(id)).toMatchObject({ status: 'completed', detail: '3/10 agents done' }) + const view = jobsOf(ctx).get(id) + expect(view.status).toBe('completed') + expect(view.progress).toBeUndefined() + expect(view.detail).toBeUndefined() }) - it('a teardown-forced settlement also ends the record', async () => { + it('a teardown-forced settlement also ends the ring', async () => { const ctx = await harness() const warn = vi.fn() ctx.logger.warn = warn as never - const owner = stubAgent(ctx, 'owner') - ctx.agents.register(owner) - const p = producer({ owner, record: true }) + const owner = liveAgent(ctx, 'owner') + const p = producer({ owner }) const id = ctx.jobs.start(p.spec) p.job().append('partial') const disposal = disposeAgentScope(owner) p.settle({ status: 'killed' }) await disposal - expect(() => ctx.jobs.readRecord(id, 0, owner)).toThrow(/unknown job/) + expect(() => jobsOf(ctx, owner).readAt(id, 0)).toThrow(/unknown job/) // The producer face outlives the row; its trailing flush is dropped, not thrown. p.job().append('late') expect(warn).toHaveBeenCalledWith(expect.stringContaining('append to settled job')) }) - it('delivers onOutput to the global layer and to the owning scope only', async () => { + it('hands out fresh projections, never live registry state', async () => { const ctx = await harness() - const mount = createScope(ctx, {}) - const otherMount = createScope(ctx, {}) - const alice = stubAgent(ctx, 'alice', scopeOf(mount.ctx)) - const bob = stubAgent(ctx, 'bob', scopeOf(otherMount.ctx)) - ctx.agents.register(alice) - ctx.agents.register(bob) - const globalSignals: string[] = [] - ctx.jobs.onOutput((id) => { globalSignals.push(String(id)) }) - const scopedSignals: string[] = [] - await mount.ctx.plugin({ - inject: ['jobs'], - apply(scopedCtx: Context) { - scopedCtx.jobs.onOutput((id) => { scopedSignals.push(String(id)) }) - }, - }) - const pa = producer({ owner: alice, record: true }) - const aliceId = ctx.jobs.start(pa.spec) - const pb = producer({ owner: bob, record: true }) - ctx.jobs.start(pb.spec) - pa.job().append('for alice observers') - pb.job().append('for bob observers') - expect(globalSignals).toHaveLength(2) - expect(scopedSignals).toEqual([String(aliceId)]) - }) - - it('contains a throwing onOutput listener without breaking the append commit', async () => { - const ctx = await harness() - const warn = vi.fn() - ctx.logger.warn = warn as never - ctx.jobs.onOutput(() => { throw new Error('output boom') }) - const p = producer({ record: true }) + const p = producer() const id = ctx.jobs.start(p.spec) - p.job().append('x') - expect(ctx.jobs.get(id).outputTotal).toBe(1) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('onOutput listener threw')) - }) - - it('unregisters onOutput listeners with their registering fiber (HMR safety)', async () => { - const ctx = await harness() - const signals: string[] = [] - const fiber = ctx.plugin({ - inject: ['jobs'], - apply(pluginCtx: Context) { - pluginCtx.jobs.onOutput((id) => { signals.push(String(id)) }) - }, - }) - await fiber - const p = producer({ record: true }) - ctx.jobs.start(p.spec) - p.job().append('a') - expect(signals).toHaveLength(1) - await fiber.dispose() - p.job().append('b') - expect(signals).toHaveLength(1) - }) - - it('hands out fresh record snapshots, never live registry state', async () => { - const ctx = await harness() - const p = producer({ record: true }) - const id = ctx.jobs.start(p.spec) - const before = ctx.jobs.get(id) + const before = jobsOf(ctx).get(id) p.job().append('grow') - expect(before.outputTotal).toBe(0) - expect(ctx.jobs.get(id).outputTotal).toBe(4) + expect(before.output.total).toBe(0) + expect(jobsOf(ctx).get(id).output.total).toBe(4) + }) +}) + +describe('LocalJobRegistry pull sources', () => { + it('pumps the spec sources into the ring and drains them once more before settlement', async () => { + const ctx = await harness({ pumpPollMs: 1 }) + const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') + // stderr only becomes readable once the producer has settled: the pump's + // final drain is the only read that can collect it. + let finishing = false + let errOffset = 0 + const err: JobOutputSource = { + channel: 'stderr', + read() { + const text = finishing && errOffset === 0 ? 'E' : '' + errOffset += text.length + return { text, nextOffset: errOffset, lossy: false } + }, + } + const p = producer({ output: [out, err] }) + const id = ctx.jobs.start(p.spec) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(jobsOf(ctx).readAt(id, 0).chunks.map(chunk => chunk.text)).toEqual(['a', 'bc']) + + const settled = collect(ctx, { owners: 'all' }, ['settled']) + finishing = true + p.settle({ status: 'completed', detail: 'exit code: 0' }) + await new Promise(resolve => setTimeout(resolve, 10)) + // The final drain landed before the settlement announced itself. + expect(settled).toHaveLength(1) + expect(jobsOf(ctx).readAt(id, 0).chunks.map(chunk => chunk.text)).toEqual(['a', 'bc', 'E']) + expect(jobsOf(ctx).get(id)).toMatchObject({ status: 'completed', output: { total: 4 } }) + }) + + it('stops pumping silently after a forced settlement', async () => { + const ctx = await harness({ pumpPollMs: 1 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + const owner = liveAgent(ctx, 'owner') + const source: JobOutputSource = { + read: fromByte => ({ text: 'x', nextOffset: fromByte + 1, lossy: false }), + } + ctx.jobs.start({ + kind: 'bash', + label: 'endless', + owner: owner.id, + output: [source], + run: () => ({ cancel() { throw new Error('cancel boom') }, done: new Promise(() => {}) }), + }) + await new Promise(resolve => setTimeout(resolve, 5)) + await disposeAgentScope(owner) + await new Promise(resolve => setTimeout(resolve, 5)) + // The forced failure is the only warning: the pump's later drains are silent. + expect(warn.mock.calls.map(call => String(call[0]))).toEqual([expect.stringContaining('work may be orphaned')]) }) }) From 6626c74dbc66b292061cbde6063c12654a30bde8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 16:09:07 +0800 Subject: [PATCH 030/141] refactor(jobs-local): contain a throwing pull source instead of freezing the job --- packages/jobs/jobs-local/src/index.ts | 28 ++++++++++++++++++--- packages/jobs/jobs-local/tests/jobs.spec.ts | 17 +++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 965de7a124..165cf83aff 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -18,8 +18,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' import type { - JobAppendOptions, JobEvent, JobEvents, JobHandle, JobKillOptions, JobKind, JobOutcome, JobOutputRead, JobRead, - JobSettleCause, JobSpec, JobStatus, JobView, VisibleJobs, + JobAppendOptions, JobEvent, JobEvents, JobHandle, JobKillOptions, JobKind, JobOutcome, JobOutputRead, JobOutputSource, + JobRead, JobSettleCause, JobSpec, JobStatus, JobView, VisibleJobs, } from '@deepseek-ai/dsh-jobs' import { JobEventHub, JobLayer } from './events.ts' import { startPump } from './pump.ts' @@ -270,7 +270,7 @@ export class LocalJobRegistry extends JobRegistry { ) if (spec.output !== undefined && spec.output.length > 0) { job.pump = startPump( - spec.output, + spec.output.map(source => this.guardSource(job, source)), (text, options) => { this.appendRing(state, ring, text, options, 'pump') }, this.pumpPollMs, Promise.race([producerDone, settled]), @@ -485,6 +485,28 @@ export class LocalJobRegistry extends JobRegistry { if (job !== undefined) this.emitOutput(job) } + /** + * Contain a failing pull source: the first throw is logged, and the source + * reads as exhausted from then on, so the job runs to its own settlement + * with whatever the ring holds instead of freezing on a pump failure. + */ + private guardSource(job: TrackedJob, source: JobOutputSource): JobOutputSource { + let failed = false + return { + ...source.channel !== undefined ? { channel: source.channel } : {}, + read: (fromByte) => { + if (failed) return { text: '', nextOffset: fromByte, lossy: false } + try { + return source.read(fromByte) + } catch (error: unknown) { + failed = true + this.selfCtx.logger.warn(`jobs: output source for ${job.id} failed; its stream stops here: ${String(error)}`) + return { text: '', nextOffset: fromByte, lossy: false } + } + }, + } + } + /** Announce that one job's ring advanced (append or settlement). */ private emitOutput(job: TrackedJob): void { const owner = job.owner?.id diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 550a713f6b..6f65916157 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -1311,6 +1311,23 @@ describe('LocalJobRegistry pull sources', () => { expect(jobsOf(ctx).get(id)).toMatchObject({ status: 'completed', output: { total: 4 } }) }) + it('contains a throwing source: warns once, keeps the job live, and settles on the producer', async () => { + const ctx = await harness({ pumpPollMs: 1 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + const broken: JobOutputSource = { channel: 'stdout', read() { throw new Error('reader boom') } } + const healthy = scriptedSource([{ text: 'ok' }], 'stderr') + const p = producer({ output: [broken, healthy] }) + const id = ctx.jobs.start(p.spec) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(jobsOf(ctx).get(id).status).toBe('running') + expect(jobsOf(ctx).readAt(id, 0).chunks).toEqual([{ at: 0, text: 'ok', channel: 'stderr' }]) + expect(warn.mock.calls.map(call => String(call[0]))).toEqual([expect.stringContaining(`output source for ${id} failed`)]) + p.settle({ status: 'completed', detail: 'exit code: 0' }) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(jobsOf(ctx).get(id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) + }) + it('stops pumping silently after a forced settlement', async () => { const ctx = await harness({ pumpPollMs: 1 }) const warn = vi.fn() From 0e35952802da6648f09fe5e824fd48c928983349 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 16:30:57 +0800 Subject: [PATCH 031/141] refactor(tool-jobs): render model reads from the ring and own the notice ledger - job_output reads the per-job ring through visibleTo(): stdout, then one [stderr] section, log narration excluded, a dropped-output notice on a lossy read, and the result exactly once after settlement - completion notices come from the registry event stream subscribed with { owners: 'scope' }; a wait claims its job when it starts and withdraws on timeout or abort, job_kill claims on 'requested', teardown settlements and unowned jobs are skipped, and a removal drops the ledger entry - the notice destination is the agent registered for the owner session at settlement, so tool-jobs now injects the agent registry - JobView exposes the producer's outputLimitBytes for the tool-result bound - lint: hub disposer identity comment, unknown-typed matchers in seam tests --- packages/jobs/jobs-local/src/events.ts | 4 +- packages/jobs/jobs-local/src/index.ts | 2 +- packages/jobs/jobs-local/tests/jobs.spec.ts | 22 +- packages/jobs/jobs/src/view.ts | 2 + packages/jobs/jobs/tests/service.spec.ts | 2 +- packages/jobs/tool-jobs/src/index.ts | 153 ++++---- packages/jobs/tool-jobs/src/render.ts | 80 ++++ .../jobs/tool-jobs/tests/tool-jobs.spec.ts | 354 ++++++++++++------ 8 files changed, 433 insertions(+), 186 deletions(-) create mode 100644 packages/jobs/tool-jobs/src/render.ts diff --git a/packages/jobs/jobs-local/src/events.ts b/packages/jobs/jobs-local/src/events.ts index 2e39f7089f..333d5db5fc 100644 --- a/packages/jobs/jobs-local/src/events.ts +++ b/packages/jobs/jobs-local/src/events.ts @@ -62,10 +62,12 @@ export class JobEventHub { if ('owners' in filter && filter.owners === 'scope') { return this.layers.effect(ctx, layer => layer.scoped.append(subscription), { label: 'jobs.events.subscribe()' }) } - return ctx.effect(() => { + const dispose = ctx.effect(() => { this.unscoped.add(subscription) return () => { this.unscoped.delete(subscription) } }, 'jobs.events.subscribe()') + // oxlint-disable-next-line typescript/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity + return dispose } /** diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 165cf83aff..990c03209c 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -177,7 +177,7 @@ export class LocalJobRegistry extends JobRegistry { this.settledRetainBytes = resolved.settledRetainBytes this.pumpPollMs = resolved.pumpPollMs this.selfCtx = ctx - this.hub = new JobEventHub(this.layers, message => ctx.logger.warn(message)) + this.hub = new JobEventHub(this.layers, (message) => { ctx.logger.warn(message) }) ctx.effect(() => () => this.disposeAll(), 'jobs teardown') } diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 6f65916157..55e5615a58 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -395,7 +395,7 @@ describe('LocalJobRegistry reads and settlement', () => { p.settle({ status: 'completed', detail: 'exit code: 0' }) await tick() - expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id, status: 'completed', detail: 'exit code: 0' }) }]) + expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id, status: 'completed', detail: 'exit code: 0' }) as unknown }]) expect(warn).toHaveBeenCalledWith(expect.stringContaining('listener boom')) }) @@ -440,14 +440,14 @@ describe('LocalJobRegistry.kill', () => { expect(jobsOf(ctx).kill(id, { reason: 'no longer needed' })).toBe('requested') expect(p.cancels).toEqual(['no longer needed']) expect(jobsOf(ctx).list()[0]).toMatchObject({ status: 'stopping' }) - expect(seen).toEqual([{ type: 'stopping', job: expect.objectContaining({ id, status: 'stopping' }) }]) + expect(seen).toEqual([{ type: 'stopping', job: expect.objectContaining({ id, status: 'stopping' }) as unknown }]) p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) await tick() expect(seen[1]).toEqual({ type: 'settled', cause: 'kill', - job: expect.objectContaining({ id, status: 'killed', detail: 'signal: SIGTERM; no longer needed' }), + job: expect.objectContaining({ id, status: 'killed', detail: 'signal: SIGTERM; no longer needed' }) as unknown, }) }) @@ -495,7 +495,7 @@ describe('LocalJobRegistry.kill', () => { expect(seen).toEqual([]) settle({ status: 'completed' }) await tick() - expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id }) }]) + expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id }) as unknown }]) broken = false expect(jobsOf(ctx).kill(id)).toBe('already-finished') @@ -587,7 +587,7 @@ describe('LocalJobRegistry.wait', () => { p.settle({ status: 'completed', detail: 'exit code: 0' }) controller.abort() await expect(wait).rejects.toThrow('wait aborted') - expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id, status: 'completed' }) }]) + expect(seen).toEqual([{ type: 'settled', cause: 'producer', job: expect.objectContaining({ id, status: 'completed' }) as unknown }]) }) it('an abort landing after settlement still delivers the terminal projection it owes', async () => { @@ -751,7 +751,7 @@ describe('LocalJobRegistry owner cleanup', () => { }) await disposeAgentScope(owner) - expect(seen).toEqual([{ type: 'settled', cause: 'teardown', job: expect.objectContaining({ status: 'killed' }) }]) + expect(seen).toEqual([{ type: 'settled', cause: 'teardown', job: expect.objectContaining({ status: 'killed' }) as unknown }]) }) it('attaches one cleanup per owner and drains all owned jobs with the scope', async () => { @@ -933,7 +933,7 @@ describe('LocalJobRegistry disposal', () => { expect(disposedWithoutProducerDone).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned')) - expect(seen).toEqual([{ type: 'settled', cause: 'teardown', job: expect.objectContaining({ status: 'failed' }) }]) + expect(seen).toEqual([{ type: 'settled', cause: 'teardown', job: expect.objectContaining({ status: 'failed' }) as unknown }]) }) it('detaches owner effects from still-live agent scopes when the service unloads', async () => { @@ -1012,14 +1012,14 @@ describe('LocalJobRegistry events', () => { const p = producer({ owner }) const id = ctx.jobs.start(p.spec) // Registration is announced only once the record is readable. - expect(seen).toEqual([{ type: 'registered', job: expect.objectContaining({ id, owner: 'alice', status: 'running' }) }]) + expect(seen).toEqual([{ type: 'registered', job: expect.objectContaining({ id, owner: 'alice', status: 'running' }) as unknown }]) expect(jobsOf(ctx, owner).list()).toHaveLength(1) p.job().updateProgress('3/10') - expect(seen[1]).toEqual({ type: 'progress', job: expect.objectContaining({ id, progress: '3/10' }) }) + expect(seen[1]).toEqual({ type: 'progress', job: expect.objectContaining({ id, progress: '3/10' }) as unknown }) expect(jobsOf(ctx, owner).kill(id)).toBe('requested') - expect(seen[2]).toEqual({ type: 'stopping', job: expect.objectContaining({ id, status: 'stopping', progress: '3/10' }) }) + expect(seen[2]).toEqual({ type: 'stopping', job: expect.objectContaining({ id, status: 'stopping', progress: '3/10' }) as unknown }) p.settle({ status: 'killed' }) await tick() @@ -1033,7 +1033,7 @@ describe('LocalJobRegistry events', () => { const ctx = await harness() const seen = collect(ctx, { owner: SessionId('anyone') }, ['registered']) ctx.jobs.start(producer().spec) - expect(seen).toEqual([{ type: 'registered', job: expect.objectContaining({ id: 'bash-1' }) }]) + expect(seen).toEqual([{ type: 'registered', job: expect.objectContaining({ id: 'bash-1' }) as unknown }]) expect((seen[0] as { job: JobView }).job.owner).toBeUndefined() }) diff --git a/packages/jobs/jobs/src/view.ts b/packages/jobs/jobs/src/view.ts index 033fcfbe76..d3c73ae018 100644 --- a/packages/jobs/jobs/src/view.ts +++ b/packages/jobs/jobs/src/view.ts @@ -62,6 +62,8 @@ export interface JobView { readonly label: string /** Owning session; absent for an unowned job, which every caller can see. */ readonly owner?: SessionId + /** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */ + readonly outputLimitBytes?: number /** Current lifecycle state. */ readonly status: JobStatus /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */ diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index f3b456acbc..5bcde91862 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -77,7 +77,7 @@ describe('JobRegistry seam', () => { expect(jobs.list()).toHaveLength(1) expect(jobs.get(id).status).toBe('running') expect(jobs.get(id).owner).toBe(caller) - expect(jobs.read(id)).toEqual({ chunks: [], lossy: false, job: expect.objectContaining({ id }) }) + expect(jobs.read(id)).toEqual({ chunks: [], lossy: false, job: expect.objectContaining({ id }) as unknown }) expect(jobs.readAt(id, 7)).toEqual({ chunks: [], next: 7, lossy: false }) expect(jobs.kill(id, { reason: 'seam test' })).toBe('requested') await expect(jobs.wait(id, 5)).resolves.toMatchObject({ id }) diff --git a/packages/jobs/tool-jobs/src/index.ts b/packages/jobs/tool-jobs/src/index.ts index 325766ee2b..d189182b72 100644 --- a/packages/jobs/tool-jobs/src/index.ts +++ b/packages/jobs/tool-jobs/src/index.ts @@ -1,9 +1,10 @@ /** * Model-facing `job_output`, `job_list`, and `job_kill` tools over * `ctx.jobs`. Loading the plugin attaches the controller required by - * producers. It also delivers unreported completions to the owning agent: - * injected into a busy owner's next step, or opening a turn on an idle one - * under the default `wakeup` delivery, bounded per owner. + * producers. It also delivers completions the model has not already + * collected to the owning agent: injected into a busy owner's next step, or + * opening a turn on an idle one under the default `wakeup` delivery, bounded + * per owner. * @module @deepseek-ai/dsh-tool-jobs */ @@ -14,14 +15,19 @@ import { TextRetainer } from '@deepseek-ai/dsh-output-retention' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' import { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' +import type { JobView, VisibleJobs } from '@deepseek-ai/dsh-jobs' import type { Agent } from '@deepseek-ai/dsh-agent' +import { jobDetail, publicJob, renderModelDelta, statusLine } from './render.ts' +import type { PublicJobSnapshot } from './render.ts' + +export { publicJob, renderModelDelta, statusLine } from './render.ts' +export type { PublicJobSnapshot } from './render.ts' export const name = 'tool-jobs' -export const inject = ['tools', 'jobs', 'systemPrompt'] +export const inject = ['tools', 'jobs', 'agents', 'systemPrompt'] /** - * How an unreported completion reaches an owner that is already idle: `wakeup` + * How an uncollected completion reaches an owner that is already idle: `wakeup` * opens a turn for it, `quiet` leaves it pending until something else wakes the * owner. A busy owner is injected either way. */ @@ -51,17 +57,6 @@ export const Config: z = z.object({ maxConsecutiveWakes: z.number().min(1).default(3), }) -/** Task state safe for model-authored programs; ownership/bookkeeping fields are omitted. */ -export interface PublicJobSnapshot { - id: string - kind: string - label: string - status: JobSnapshot['status'] - detail?: string - startedAt: number - finishedAt?: number -} - /** Shared schema for job-control outputs. */ const PUBLIC_TASK_SCHEMA = { type: 'object', @@ -81,30 +76,6 @@ const PUBLIC_TASK_SCHEMA = { }, } as const -/** Remove job ownership and notification bookkeeping from a registry snapshot. */ -function publicJob(snapshot: JobSnapshot): PublicJobSnapshot { - return { - id: snapshot.id, - kind: snapshot.kind, - label: snapshot.label, - status: snapshot.status, - ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, - startedAt: snapshot.startedAt, - ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, - } -} - -/** - * Render generic status with optional producer detail. - * @param snapshot - job state to render. - * @returns a bracketed status line. - */ -export function statusLine(snapshot: Pick): string { - return snapshot.detail !== undefined - ? `[status: ${snapshot.status}, ${snapshot.detail}]` - : `[status: ${snapshot.status}]` -} - const encoder = new TextEncoder() function retainTail(text: string, maxBytes: number): string { @@ -135,19 +106,19 @@ function fitWithSuffix( /** * One-line account of a settled job for the `notice` form's collapsed row. - * @param snapshot - the settled job. + * @param job - the settled job. * @returns its kind, label, and status, bounded like every notice summary. */ -function completionSummary(snapshot: JobSnapshot): string { - return boundContextSummary(`${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`) +function completionSummary(job: JobView): string { + return boundContextSummary(`${job.kind} ${job.label} ${statusLine({ status: job.status, detail: jobDetail(job) })}`) } -function fitCompletionNotice(snapshot: JobSnapshot): string { - const prefix = `background job ${snapshot.id}` - const detail = ` (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}` +function fitCompletionNotice(job: JobView): string { + const prefix = `background job ${job.id}` + const detail = ` (${job.kind}: ${job.label}) finished ${statusLine({ status: job.status, detail: jobDetail(job) })}` const action = '\nDone; job_output.' const complete = `${prefix}${detail}. Read its output with job_output.` - const maxBytes = snapshot.outputLimitBytes + const maxBytes = job.outputLimitBytes if (maxBytes === undefined || encoder.encode(complete).byteLength <= maxBytes) return complete const omitted = '\n[notice truncated]' const fixed = `${prefix}${omitted}${action}` @@ -181,11 +152,12 @@ function boundSingleText(content: readonly ContentBlock[], maxBytes: number): Co }] } +/** The producer's cap for the job a `job_output` or `job_kill` call names, when it is visible to the caller. */ function visibleOutputLimit(ctx: Context, exec: ToolExecution): number | undefined { if (exec.name !== 'job_output' && exec.name !== 'job_kill') return undefined const jobId = (exec.arguments as { job_id?: unknown } | null | undefined)?.job_id if (typeof jobId !== 'string' || jobId.length === 0) return undefined - return ctx.jobs.list(exec.agent).find(snapshot => snapshot.id === jobId)?.outputLimitBytes + return ctx.jobs.visibleTo(exec.agent?.id).list().find(job => job.id === jobId)?.outputLimitBytes } /** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */ @@ -201,6 +173,21 @@ function presentTaskCall(title: string, kind: 'read' | 'execute', rawInput?: str return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} } } +/** True for the three terminal statuses. */ +function isTerminal(job: JobView): boolean { + return job.status === 'completed' || job.status === 'killed' || job.status === 'failed' +} + +/** The consuming read as the model sees it: the delta, then the result once, then the status line. */ +function readBody(jobs: VisibleJobs, id: JobId): { text: string; job: PublicJobSnapshot } { + const read = jobs.read(id) + const delta = renderModelDelta(read.chunks, read.lossy) + const text = read.result === undefined + ? delta + : `${delta}${delta.length > 0 && !delta.endsWith('\n') ? '\n' : ''}${read.result}` + return { text, job: publicJob(read.job) } +} + export function apply(ctx: Context, config: Config): void { const waitDefault = config.waitTimeoutMs ?? 30_000 const waitCap = config.maxWaitTimeoutMs ?? 600_000 @@ -262,31 +249,52 @@ export function apply(ctx: Context, config: Config): void { ctx.systemPrompt.section({ name: 'tool:jobs', order: ctx.systemPrompt.getSectionOrder('TOOL_JOBS'), - text: 'Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job\'s work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.', + text: 'Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not stop your turn just to wait. Use job_output when you need its output now. Use job_kill to stop a job you no longer need.', }) - // Use the exact lifecycle owner; reusable ids could resolve to a replacement. + // Jobs whose terminal state this plugin already handed to the model through + // a tool result: a terminal `job_output`, a wait that returned it, or a + // `job_kill` it requested. A wait claims when it starts, not when it + // returns — settlement releases waiters before it announces, but their + // continuations run after the event — and withdraws the claim if the job + // outlives the wait. The settlement event drops the entry, so the ledger + // holds only live jobs the model already touched. + const delivered = new Set() + const claim = (id: JobId): (() => void) => { + delivered.add(id) + return () => { delivered.delete(id) } + } + // A busy owner is injected: the notice waits in its next-step inbox, which // the turn cannot close over, so jobs settling together cost one step. An // idle owner is woken instead, because an unclaimed notice is a completion // the model never learns about. Either way, disposal before the claim - // discards it with the owner, and teardown settlements arrive `reported`. + // discards it with the owner, and a teardown settlement has no reader left. // - // The registry routes each settlement to the listeners its owner's scope - // chain reaches, so a mount under one preset never sees another preset's - // agents; this listener owns delivery, not the choice of whom to deliver to. - ctx.jobs.onJobDone((snapshot, owner) => { - if (snapshot.reported || owner === undefined) return + // The registry routes each settlement to the scope this plugin was mounted + // under, so a mount under one preset never sees another preset's agents; + // this listener owns delivery, not the choice of whom to deliver to. + ctx.jobs.events.subscribe({ owners: 'scope' }, (event) => { + if (event.type === 'removed') { + delivered.delete(event.job.id) + return + } + if (event.type !== 'settled') return + const claimed = delivered.delete(event.job.id) + if (claimed || event.cause === 'teardown' || event.job.owner === undefined) return + // Use the exact lifecycle owner; a session whose agent left has no inbox. + const owner = ctx.agents.get(event.job.owner) + if (owner === undefined) return const message = createUserMessage({ content: [{ type: 'text', - text: fitCompletionNotice(snapshot), + text: fitCompletionNotice(event.job), }], source: { kind: 'plugin', plugin: 'tool-jobs', form: 'notice', - summary: completionSummary(snapshot), + summary: completionSummary(event.job), }, }) const spent = spentWakes.get(owner) ?? 0 @@ -328,12 +336,19 @@ export function apply(ctx: Context, config: Config): void { }, async execute(args, exec) { const id = validateJobId(args.job_id) + const jobs = ctx.jobs.visibleTo(exec.agent?.id) if (args.wait === true) { const timeout = Math.min(args.timeout_ms ?? waitDefault, waitCap) - await ctx.jobs.wait(id, timeout, exec.agent, exec.signal) + const withdraw = claim(id) + try { + const view = await jobs.wait(id, timeout, exec.signal) + if (!isTerminal(view)) withdraw() + } catch (error: unknown) { + withdraw() + throw error + } } - const read = ctx.jobs.read(id, exec.agent) - return { text: read.text, job: publicJob(read.snapshot) } + return readBody(jobs, id) }, presentCall: args => presentTaskCall(`Read output from background job ${args.job_id}`, 'read', args.job_id), })) @@ -352,7 +367,7 @@ export function apply(ctx: Context, config: Config): void { }], }, execute(_args, exec) { - const jobs = ctx.jobs.list(exec.agent) + const jobs = ctx.jobs.visibleTo(exec.agent?.id).list() return Promise.resolve(jobs.map(publicJob)) }, presentCall: () => presentTaskCall('List background jobs', 'read'), @@ -388,12 +403,16 @@ export function apply(ctx: Context, config: Config): void { }, execute(args, exec) { const id = validateJobId(args.job_id) - const result = ctx.jobs.kill(id, exec.agent, args.reason) - // A snapshot describes current state without consuming pending output. - const snapshot = publicJob(ctx.jobs.get(id, exec.agent)) + const jobs = ctx.jobs.visibleTo(exec.agent?.id) + const result = jobs.kill(id, args.reason === undefined ? {} : { reason: args.reason }) + // The model's own kill is its delivery: the settlement notice would only + // repeat what this tool result already said. + if (result === 'requested') claim(id) + // A projection describes current state without consuming pending output. + const job = publicJob(jobs.get(id)) return Promise.resolve({ outcome: result === 'already-finished' ? 'already-finished' as const : 'cancellation-requested' as const, - job: snapshot, + job, }) }, presentCall: args => presentTaskCall(`Kill background job ${args.job_id}`, 'execute', args.job_id), diff --git a/packages/jobs/tool-jobs/src/render.ts b/packages/jobs/tool-jobs/src/render.ts new file mode 100644 index 0000000000..74b0255cbd --- /dev/null +++ b/packages/jobs/tool-jobs/src/render.ts @@ -0,0 +1,80 @@ +/** + * Model-facing rendering of registry reads: the consuming delta as the + * shell tools have always shown it (stdout, then one marked stderr section), + * the `[status: …]` line, and the public job projection the tool schemas + * expose. + * @module @deepseek-ai/dsh-tool-jobs/render + */ + +import type { JobChunk, JobView } from '@deepseek-ai/dsh-jobs' + +/** Job state safe for model-authored programs; ownership and offsets are omitted. */ +export interface PublicJobSnapshot { + id: string + kind: string + label: string + status: JobView['status'] + /** The live progress line while running, the terminal reason once settled. */ + detail?: string + startedAt: number + finishedAt?: number +} + +/** + * The one-line qualifier the model reads beside a status: live progress while + * the job runs, the terminal reason once it settled. + * @param job - the job projection. + * @returns the qualifier, or undefined when the job carries neither. + */ +export function jobDetail(job: Pick): string | undefined { + return job.progress ?? job.detail +} + +/** + * Remove ownership, offsets, and limits from a registry projection. + * @param job - the registry projection. + * @returns the model-safe projection. + */ +export function publicJob(job: JobView): PublicJobSnapshot { + const detail = jobDetail(job) + return { + id: job.id, + kind: job.kind, + label: job.label, + status: job.status, + ...detail !== undefined ? { detail } : {}, + startedAt: job.startedAt, + ...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {}, + } +} + +/** + * Render generic status with optional detail. + * @param snapshot - job state to render. + * @returns a bracketed status line. + */ +export function statusLine(snapshot: Pick): string { + return snapshot.detail !== undefined + ? `[status: ${snapshot.status}, ${snapshot.detail}]` + : `[status: ${snapshot.status}]` +} + +/** + * Render one consuming read for the model: stdout and unlabeled chunks in + * order, then every stderr chunk in one `[stderr]` section, exactly as the + * shell tools render a foreground result. `log` chunks are producer + * narration for observers and never reach the model. A lossy read — the + * cursor fell behind the ring's retention — ends with a dropped-output + * notice. + * @param chunks - the chunks since the model cursor, in offset order. + * @param lossy - whether bytes before `chunks` were evicted unread. + * @returns the delta text, possibly empty. + */ +export function renderModelDelta(chunks: readonly JobChunk[], lossy: boolean): string { + const out = chunks.filter(chunk => chunk.channel === undefined || chunk.channel === 'stdout').map(chunk => chunk.text).join('') + const err = chunks.filter(chunk => chunk.channel === 'stderr').map(chunk => chunk.text).join('') + const separator = out.length > 0 && !out.endsWith('\n') ? '\n' : '' + const body = out + (err.length > 0 ? `${separator}[stderr]\n${err}` : '') + if (!lossy) return body + return `${body}${body.length > 0 && !body.endsWith('\n') ? '\n' : ''}[some output was dropped from memory before this read]` +} diff --git a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts index 4c6a81e48a..6c792226d4 100644 --- a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts +++ b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts @@ -10,21 +10,26 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import { JobId } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -import type { JobHooks, JobOutcome, JobSnapshot, JobStart } from '@deepseek-ai/dsh-jobs' +import type { JobAppendOptions, JobHandle, JobHooks, JobOutcome, JobSpec, JobView } from '@deepseek-ai/dsh-jobs' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' -import { statusLine } from '@deepseek-ai/dsh-tool-jobs' +import { publicJob, renderModelDelta, statusLine } from '@deepseek-ai/dsh-tool-jobs' const testToolSignal = new AbortController().signal const agentRegistryDisposers = new WeakMap void>() const agentScopeFibers = new WeakMap Promise }>() -async function setup(config: ToolTasks.Config = {}) { +/** Registry knobs a test may narrow; every producer here pushes, so the pump stays idle. */ +interface RegistryConfig { + retainBytes?: number +} + +async function setup(config: ToolTasks.Config = {}, registry: RegistryConfig = {}) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) const agentsFiber = await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) + await ctx.plugin(LocalJobRegistry, registry) const toolsFiber = await ctx.plugin(ToolTasks, config) return { ctx, agentsFiber, toolsFiber } } @@ -70,9 +75,13 @@ async function disposeAgentScope(agent: Agent): Promise { await fiber.dispose() } -/** A controllable producer start-spec (settle `done` on demand, record cancels). */ -function producer(overrides: Partial & JobHooks> = {}) { +/** + * A controllable producer spec: pushes output and progress through the handle + * the registry hands its starter, settles `done` on demand, records cancels. + */ +function producer(overrides: Partial & JobHooks> = {}) { let settle!: (outcome: JobOutcome) => void + let handle: JobHandle | undefined const cancels: (string | undefined)[] = [] const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides const hooks: JobHooks = { @@ -80,14 +89,24 @@ function producer(overrides: Partial & JobHooks> = {}) { done: new Promise((res) => { settle = res }), ...hookOverrides, } - const spec: JobStart = { + const spec: JobSpec = { kind, label, ...owner !== undefined ? { owner } : {}, ...outputLimitBytes !== undefined ? { outputLimitBytes } : {}, - run: () => hooks, + run: (job) => { handle = job; return hooks }, + } + const started = (): JobHandle => { + if (handle === undefined) throw new Error('producer not started') + return handle + } + return { + spec, + settle, + cancels, + append: (text: string, options?: JobAppendOptions) => { started().append(text, options) }, + progress: (line: string) => { started().updateProgress(line) }, } - return { spec, settle, cancels } } let callCounter = 0 @@ -104,7 +123,7 @@ const tick = () => new Promise(r => setTimeout(r, 0)) /** Start and settle `count` owned jobs one at a time, letting each notice land. */ async function settleTasks(ctx: Context, owner: Agent, count: number): Promise { for (let i = 0; i < count; i += 1) { - const p = producer({ owner }) + const p = producer({ owner: owner.id }) ctx.jobs.start(p.spec) p.settle({ status: 'completed' }) await tick() @@ -119,10 +138,18 @@ describe('tool-jobs setup', () => { expect(() => ctx.jobs.start(producer().spec)).toThrow('no job controller serves this agent') }) + it('leaves with the agent registry it resolves owners through', async () => { + const { ctx, agentsFiber } = await setup() + await agentsFiber.dispose() + expect(ctx.tools.get('job_output')).toBeUndefined() + expect(() => ctx.jobs.start(producer().spec)).toThrow('no job controller serves this agent') + }) + it('rejects a config whose default wait exceeds the cap', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await expect(ctx.plugin(ToolTasks, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 })) .rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)') @@ -141,6 +168,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) try { await ctx.plugin(ToolTasks, { maxConsecutiveWakes }) @@ -158,9 +186,35 @@ describe('tool-jobs setup', () => { }) it('renders status lines with and without producer detail', () => { - const base = { id: 'bash-1', kind: 'bash', label: 'x', startedAt: 0, reported: false } as unknown as JobSnapshot - expect(statusLine({ ...base, status: 'running' })).toBe('[status: running]') - expect(statusLine({ ...base, status: 'completed', detail: 'exit code: 0' })).toBe('[status: completed, exit code: 0]') + expect(statusLine({ status: 'running' })).toBe('[status: running]') + expect(statusLine({ status: 'completed', detail: 'exit code: 0' })).toBe('[status: completed, exit code: 0]') + }) + + it('projects the public job: live progress or terminal detail, never ownership or offsets', () => { + const live: JobView = { + id: JobId('bash-1'), kind: 'bash', label: 'x', owner: SessionId('sess-1'), outputLimitBytes: 8, + status: 'running', progress: '3/10 files', startedAt: 5, output: { total: 12, earliest: 0 }, + } + expect(publicJob(live)).toEqual({ id: 'bash-1', kind: 'bash', label: 'x', status: 'running', detail: '3/10 files', startedAt: 5 }) + const settled: JobView = { + id: JobId('bash-1'), kind: 'bash', label: 'x', status: 'completed', detail: 'exit code: 0', + startedAt: 5, finishedAt: 9, output: { total: 12, earliest: 0 }, + } + expect(publicJob(settled)).toEqual({ id: 'bash-1', kind: 'bash', label: 'x', status: 'completed', detail: 'exit code: 0', startedAt: 5, finishedAt: 9 }) + }) + + it('renders a model delta as stdout, one stderr section, and a dropped-output notice', () => { + expect(renderModelDelta([], false)).toBe('') + expect(renderModelDelta([], true)).toBe('[some output was dropped from memory before this read]') + expect(renderModelDelta([ + { at: 0, text: 'a' }, + { at: 1, text: 'e1\n', channel: 'stderr' }, + { at: 4, text: 'narration', channel: 'log' }, + { at: 13, text: 'b', channel: 'stdout' }, + { at: 14, text: 'e2', channel: 'stderr' }, + ], false)).toBe('ab\n[stderr]\ne1\ne2') + expect(renderModelDelta([{ at: 0, text: 'tail' }], true)).toBe('tail\n[some output was dropped from memory before this read]') + expect(renderModelDelta([{ at: 0, text: 'line\n' }], true)).toBe('line\n[some output was dropped from memory before this read]') }) it('applies the built-in wait bounds when apply() receives a bare config', async () => { @@ -169,6 +223,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) ToolTasks.apply(ctx, {}) expect(ctx.tools.get('job_output')).toBeDefined() @@ -179,8 +234,9 @@ describe('tool-jobs setup', () => { describe('job_output', () => { it('reads a consuming delta with a trailing status line', async () => { const { ctx } = await setup() - const chunks = ['line one\n', ''] - ctx.jobs.start(producer({ readOutput: () => chunks.shift() ?? '' }).spec) + const p = producer() + ctx.jobs.start(p.spec) + p.append('line one\n') // A body already ending in a newline gets no doubled separator. const first = await call(ctx, 'job_output', { job_id: 'bash-1' }) @@ -190,29 +246,75 @@ describe('job_output', () => { text: 'line one\n', job: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'running' }, }) - expect(firstValue.job).not.toHaveProperty('ownerSession') - expect(firstValue.job).not.toHaveProperty('reported') + expect(firstValue.job).not.toHaveProperty('owner') + expect(firstValue.job).not.toHaveProperty('output') expect(text(first)).toBe('line one\n[status: running]') expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running]') }) - it('returns the final output of a settled final-output job', async () => { + it('renders stderr in a trailing section and never shows log narration', async () => { + const { ctx } = await setup() + const p = producer() + ctx.jobs.start(p.spec) + p.append('out\n') + p.append('warn\n', { channel: 'stderr' }) + p.append('observer-only narration', { channel: 'log' }) + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('out\n[stderr]\nwarn\n[status: running]') + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running]') + }) + + it('shows live progress beside the status until settlement replaces it with the terminal detail', async () => { + const { ctx } = await setup() + const p = producer() + ctx.jobs.start(p.spec) + p.progress('3/10 files') + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running, 3/10 files]') + p.settle({ status: 'completed', detail: 'exit code: 0' }) + await tick() + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: completed, exit code: 0]') + }) + + it('flags a read whose cursor fell behind the ring', async () => { + const { ctx } = await setup({}, { retainBytes: 16 }) + const p = producer() + ctx.jobs.start(p.spec) + p.append('x'.repeat(40)) + const first = text(await call(ctx, 'job_output', { job_id: 'bash-1' })) + expect(first).toContain('[some output was dropped from memory before this read]') + expect(first).toContain('[status: running]') + expect(first).not.toContain('x'.repeat(17)) + // The cursor now sits at the ring's end: the next read is clean. + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running]') + }) + + it('returns the result of a settled job exactly once, after the pending delta', async () => { const { ctx } = await setup() const p = producer({ kind: 'subagent', label: 'research' }) ctx.jobs.start(p.spec) expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('(no new output)\n[status: running]') - p.settle({ status: 'completed', detail: 'completed', output: 'the answer' }) + p.append('partial') + p.settle({ status: 'completed', detail: 'completed', result: 'the answer' }) await tick() - expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('the answer\n[status: completed, completed]') + expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('partial\nthe answer\n[status: completed, completed]') + expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('(no new output)\n[status: completed, completed]') + }) + + it('keeps a newline-terminated delta and the result on separate lines', async () => { + const { ctx } = await setup() + const p = producer({ kind: 'subagent', label: 'research' }) + ctx.jobs.start(p.spec) + p.append('progress log\n') + p.settle({ status: 'completed', result: 'the answer' }) + await tick() + expect(text(await call(ctx, 'job_output', { job_id: 'subagent-1' }))).toBe('progress log\nthe answer\n[status: completed]') }) it('applies a producer limit to the complete body and status result', async () => { const { ctx } = await setup() - ctx.jobs.start(producer({ - outputLimitBytes: 48, - readOutput: () => '界'.repeat(100), - }).spec) + const p = producer({ outputLimitBytes: 48 }) + ctx.jobs.start(p.spec) + p.append('界'.repeat(100)) const output = text(await call(ctx, 'job_output', { job_id: 'bash-1' })) expect(Buffer.byteLength(output)).toBeLessThanOrEqual(48) @@ -221,24 +323,21 @@ describe('job_output', () => { it('preserves empty and newline-terminated output under a producer limit', async () => { const { ctx } = await setup() - const chunks = ['', 'line\n'] - ctx.jobs.start(producer({ - outputLimitBytes: 64, - readOutput: () => chunks.shift() ?? '', - }).spec) + const p = producer({ outputLimitBytes: 64 }) + ctx.jobs.start(p.spec) expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))) .toBe('(no new output)\n[status: running]') + p.append('line\n') expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))) .toBe('line\n[status: running]') }) it('bounds post-policy output without restoring the canonical status rendering', async () => { const { ctx } = await setup() - ctx.jobs.start(producer({ - outputLimitBytes: 64, - readOutput: () => 'canonical output', - }).spec) + const p = producer({ outputLimitBytes: 64 }) + ctx.jobs.start(p.spec) + p.append('canonical output') ctx.on('tools/post-execute', (exec, _result, next) => { if (exec.name !== 'job_output') return next() return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'p'.repeat(1_000) }] }) @@ -250,17 +349,30 @@ describe('job_output', () => { expect(text(result)).not.toContain('[status: running]') }) - it('applies a producer limit to a normalized read failure', async () => { + it('does not double a truncation marker a policy already appended', async () => { const { ctx } = await setup() - ctx.jobs.start(producer({ - outputLimitBytes: 64, - readOutput: () => { throw new Error('read failed: '.repeat(100)) }, - }).spec) + ctx.jobs.start(producer({ outputLimitBytes: 64 }).spec) + ctx.on('tools/post-execute', (exec, _result, next) => { + if (exec.name !== 'job_output') return next() + return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: `${'z'.repeat(1_000)}\n[result truncated]` }] }) + }) const result = await call(ctx, 'job_output', { job_id: 'bash-1' }) - expect(result.isError).toBe(true) expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64) - expect(text(result)).toContain('[result truncated]') + expect(text(result).match(/\[result truncated\]/g)).toHaveLength(1) + }) + + it('refuses a job owned by another session as an errored result', async () => { + const { ctx } = await setup() + const alice = fakeAgent(ctx, 'sess-alice') + ctx.jobs.start(producer({ owner: alice.id }).spec) + const bob = fakeAgent(ctx, 'sess-bob') + + const result = await call(ctx, 'job_output', { job_id: 'bash-1' }, bob) + expect(result.isError).toBe(true) + expect(text(result)).toContain('belongs to another session') + expect((await call(ctx, 'job_output', { job_id: 'bash-1' })).isError).toBe(true) + expect((await call(ctx, 'job_output', { job_id: 'bash-1' }, alice)).isError).toBe(false) }) it('bounds pre-, around-, and post-execute policy outcomes and failures', async () => { @@ -325,7 +437,7 @@ describe('job_output', () => { ctx.jobs.start(p.spec) const pending = call(ctx, 'job_output', { job_id: 'subagent-1', wait: true }) - p.settle({ status: 'completed', output: 'done deal' }) + p.settle({ status: 'completed', result: 'done deal' }) expect(text(await pending)).toBe('done deal\n[status: completed]') }) @@ -354,9 +466,9 @@ describe('job_list', () => { expect(text(await call(ctx, 'job_list', {}))).toBe('(no background jobs)') const alice = fakeAgent(ctx, 'sess-alice') - ctx.jobs.start(producer({ owner: alice, label: 'pnpm test' }).spec) + ctx.jobs.start(producer({ owner: alice.id, label: 'pnpm test' }).spec) ctx.jobs.start(producer({ kind: 'subagent', label: 'open research' }).spec) - const p = producer({ owner: alice, label: 'build' }) + const p = producer({ owner: alice.id, label: 'build' }) ctx.jobs.start(p.spec) p.settle({ status: 'completed', detail: 'exit code: 0' }) await tick() @@ -368,8 +480,8 @@ describe('job_list', () => { expect(listedValue[0]).toMatchObject({ id: 'bash-1', kind: 'bash', label: 'pnpm test', status: 'running' }) expect(listedValue[2]).toMatchObject({ id: 'bash-2', kind: 'bash', label: 'build', status: 'completed', detail: 'exit code: 0' }) for (const job of listedValue) { - expect(job).not.toHaveProperty('ownerSession') - expect(job).not.toHaveProperty('reported') + expect(job).not.toHaveProperty('owner') + expect(job).not.toHaveProperty('output') } expect(text(listed)).toBe([ 'bash-1 [bash] running — pnpm test', @@ -395,8 +507,8 @@ describe('job_kill', () => { outcome: 'cancellation-requested', job: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'stopping' }, }) - expect(killValue.job).not.toHaveProperty('ownerSession') - expect(killValue.job).not.toHaveProperty('reported') + expect(killValue.job).not.toHaveProperty('owner') + expect(killValue.job).not.toHaveProperty('output') expect(text(result)).toBe('requested cancellation of job bash-1') expect(p.cancels).toEqual(['superseded']) }) @@ -422,7 +534,7 @@ describe('job_kill', () => { expect(result.isError).toBe(true) expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64) expect(text(result)).toContain('[result truncated]') - expect(ctx.jobs.get(JobId('bash-1'))).toMatchObject({ status: 'running', reported: false }) + expect(ctx.jobs.visibleTo().get(JobId('bash-1')).status).toBe('running') }) it('bounds single-text post policy while preserving structured policy results', async () => { @@ -470,9 +582,9 @@ describe('job_kill', () => { it('reports an already-finished job without consuming its pending delta', async () => { const { ctx } = await setup() - let delta = 'unread tail' - const p = producer({ readOutput: () => { const d = delta; delta = ''; return d } }) + const p = producer() ctx.jobs.start(p.spec) + p.append('unread tail') p.settle({ status: 'completed', detail: 'exit code: 0' }) await tick() @@ -483,7 +595,7 @@ describe('job_kill', () => { job: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'completed', detail: 'exit code: 0' }, }) expect(text(killed)).toBe('job bash-1 had already finished [status: completed, exit code: 0]') - // The kill described the job via a non-consuming snapshot: the delta is intact. + // The kill described the job via a non-consuming projection: the delta is intact. expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('unread tail\n[status: completed, exit code: 0]') }) @@ -508,9 +620,9 @@ describe('tool-owned UI presentation (presentCall)', () => { describe('completion notices across scoped mounts', () => { /** * Two agent presets mounting `tool-jobs` over ONE host registry: each mount - * registers its own `onJobDone` listener on the shared service, and - * `settle()` broadcasts one snapshot to every listener with no scope filter. - * Only the mount whose scope the owner belongs to may deliver the notice. + * subscribes with `{ owners: 'scope' }`, which the registry files into the + * mount's own scope layer and reaches along the owner's scope chain. Only + * the mount whose scope the owner belongs to sees the settlement. */ it('delivers one notice from the owning scope when two mounts share the registry', async () => { const ctx = new Context() @@ -539,9 +651,7 @@ describe('completion notices across scoped mounts', () => { const dispose = ctx.agents.register(owner) try { - // No waiter: `settle()` leaves `reported` false, which is the only path - // that reaches the notice listeners at all. - const p = producer({ owner, label: 'pnpm test' }) + const p = producer({ owner: owner.id, label: 'pnpm test' }) ctx.jobs.start(p.spec) p.settle({ status: 'completed', detail: 'exit code: 0' }) await tick() @@ -559,7 +669,7 @@ describe('completion notice delivery', () => { const inject = vi.fn() const followup = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) - const p = producer({ owner, label: 'pnpm test' }) + const p = producer({ owner: owner.id, label: 'pnpm test' }) ctx.jobs.start(p.spec) p.settle({ status: 'completed', detail: 'exit code: 0' }) @@ -573,7 +683,7 @@ describe('completion notice delivery', () => { const inject = vi.fn() const followup = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' }) - const p = producer({ owner }) + const p = producer({ owner: owner.id }) ctx.jobs.start(p.spec) p.settle({ status: 'completed' }) @@ -621,15 +731,15 @@ describe('completion notice delivery', () => { ctx.jobs.start({ kind: 'bash', label: 'sleep 60', - owner, + owner: owner.id, run: () => ({ cancel() { settle({ status: 'killed' }) }, done: new Promise((res) => { settle = res }), }), }) - // Disposal cancels and settles the owned job. Waking here would spend a - // model request on an agent the host is destroying, once per tree layer. + // Disposal cancels and settles the owned job with a teardown cause. Waking + // here would spend a model request on an agent the host is destroying. await disposeAgentScope(owner) await tick() expect(followup).not.toHaveBeenCalled() @@ -645,17 +755,16 @@ describe('completion notice delivery', () => { ctx.jobs.start({ kind: 'bash', label: 'broken producer', - owner, + owner: owner.id, run: () => ({ cancel() { throw new Error('cancel boom') }, done: new Promise(() => {}), }), }) - // The registry force-fails the record instead of deadlocking. That path - // settles the job too, so it must claim the report as the ordinary - // teardown cancel does — otherwise a throwing producer is all it takes to - // spend a model request on an owner being destroyed. + // The registry force-fails the record instead of deadlocking. That + // settlement also announces a teardown cause, so a throwing producer is + // not enough to spend a model request on an owner being destroyed. await disposeAgentScope(owner) await tick() expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned')) @@ -682,11 +791,11 @@ describe('completion notice delivery', () => { }) describe('completion notices', () => { - it('injects a notice into the owning agent when an unreported job settles', async () => { + it('injects a notice into the owning agent when an uncollected job settles', async () => { const { ctx } = await setup() const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) - const p = producer({ owner, label: 'pnpm test' }) + const p = producer({ owner: owner.id, label: 'pnpm test' }) ctx.jobs.start(p.spec) p.settle({ status: 'completed', detail: 'exit code: 0' }) @@ -710,7 +819,7 @@ describe('completion notices', () => { const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) const first = producer({ - owner, + owner: owner.id, kind: 'subagent', label: 'x'.repeat(1_000), outputLimitBytes: 61, @@ -737,7 +846,7 @@ describe('completion notices', () => { ) const second = producer({ - owner, + owner: owner.id, kind: 'subagent', label: 'x'.repeat(1_000), outputLimitBytes: 80, @@ -764,7 +873,7 @@ describe('completion notices', () => { const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) const target = producer({ - owner, + owner: owner.id, kind: 'pty-send', label: 'x'.repeat(1_000), outputLimitBytes: 64, @@ -784,8 +893,8 @@ describe('completion notices', () => { const { ctx } = await setup() const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) - const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 }) - const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 }) + const tiny = producer({ owner: owner.id, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 }) + const short = producer({ owner: owner.id, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 }) ctx.jobs.start(tiny.spec) ctx.jobs.start(short.spec) @@ -805,7 +914,7 @@ describe('completion notices', () => { const { ctx } = await setup() const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) - const p = producer({ owner }) + const p = producer({ owner: owner.id }) ctx.jobs.start(p.spec) await call(ctx, 'job_kill', { job_id: 'bash-1' }, owner) @@ -818,15 +927,55 @@ describe('completion notices', () => { const { ctx } = await setup() const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) - const p = producer({ owner, kind: 'subagent' }) + const p = producer({ owner: owner.id, kind: 'subagent' }) ctx.jobs.start(p.spec) const pending = call(ctx, 'job_output', { job_id: 'subagent-1', wait: true }, owner) - p.settle({ status: 'completed', output: 'answer' }) + p.settle({ status: 'completed', result: 'answer' }) expect(text(await pending)).toContain('answer') + await tick() expect(inject).not.toHaveBeenCalled() }) + it('withdraws the wait claim when the wait times out, so the later settlement still notifies', async () => { + const { ctx } = await setup({ waitTimeoutMs: 10, maxWaitTimeoutMs: 20 }) + const inject = vi.fn() + const owner = fakeAgent(ctx, 'sess-1', { inject }) + const p = producer({ owner: owner.id }) + ctx.jobs.start(p.spec) + + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1', wait: true }, owner))).toBe('(no new output)\n[status: running]') + p.settle({ status: 'completed' }) + await tick() + expect(inject).toHaveBeenCalledTimes(1) + }) + + it('withdraws the wait claim when the caller aborts the wait', async () => { + const { ctx } = await setup() + const inject = vi.fn() + const owner = fakeAgent(ctx, 'sess-1', { inject }) + const p = producer({ owner: owner.id }) + ctx.jobs.start(p.spec) + + const aborter = new AbortController() + const pending = ctx.tools.execute({ + signal: aborter.signal, + callId: ToolCallId('call-aborted-wait'), + name: 'job_output', + arguments: { job_id: 'bash-1', wait: true }, + agent: owner, + }) + await tick() + aborter.abort() + const result = await pending + expect(result.isError).toBe(true) + expect(text(result)).toContain('wait aborted') + + p.settle({ status: 'completed' }) + await tick() + expect(inject).toHaveBeenCalledTimes(1) + }) + it('drops the notice for unowned jobs without throwing', async () => { const { ctx } = await setup() // Unowned: settles with nobody to notify — nothing throws. @@ -836,31 +985,45 @@ describe('completion notices', () => { await tick() }) - it('does not route an old owner completion notice to a same-session replacement', async () => { + it('delivers to the agent currently registered for the owner session', async () => { const { ctx } = await setup() - // Delivery into a tearing-down owner is a plain inject: the loop has no - // terminal state, so the notice lands in the old owner's (detached) - // session instead of throwing or re-routing. + // The settlement names the owner session; the notice goes to whichever + // agent holds that session when the job settles, which can read the job + // through the same session id. const oldInject = vi.fn() const oldOwner = fakeAgent(ctx, 'shared', { inject: oldInject }) - const p = producer({ owner: oldOwner }) + const p = producer({ owner: oldOwner.id }) ctx.jobs.start(p.spec) detachAgent(oldOwner) const replacementInject = vi.fn() - fakeAgent(ctx, 'shared', { inject: replacementInject }) + const replacement = fakeAgent(ctx, 'shared', { inject: replacementInject }) p.settle({ status: 'completed' }) await tick() - expect(oldInject).toHaveBeenCalledTimes(1) - expect(replacementInject).not.toHaveBeenCalled() + expect(oldInject).not.toHaveBeenCalled() + expect(replacementInject).toHaveBeenCalledTimes(1) + expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }, replacement))).toBe('(no new output)\n[status: completed]') + }) + + it('drops the notice when the owner session has no live agent at settlement', async () => { + const { ctx } = await setup() + const inject = vi.fn() + const owner = fakeAgent(ctx, 'sess-1', { inject }) + const p = producer({ owner: owner.id }) + ctx.jobs.start(p.spec) + + detachAgent(owner) + p.settle({ status: 'completed' }) + await tick() + expect(inject).not.toHaveBeenCalled() }) it('surfaces an inject failure through listener containment (a real bug must be visible)', async () => { const { ctx } = await setup() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const owner = fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } }) - const p = producer({ owner }) + const p = producer({ owner: owner.id }) ctx.jobs.start(p.spec) p.settle({ status: 'completed' }) await tick() @@ -868,23 +1031,4 @@ describe('completion notices', () => { // registry's per-listener containment — visible, not swallowed. expect(warn).toHaveBeenCalledWith(expect.stringContaining('unexpected inject bug')) }) - - it('keeps using the exact owner after the agent registry is gone', async () => { - const { ctx, agentsFiber } = await setup() - const inject = vi.fn() - const owner = fakeAgent(ctx, 'sess-1', { inject }) - - // Settlement must not depend on a later registry lookup: the exact owner - // supplied at start remains the destination while its own scope is live. - const p1 = producer({ owner }) - ctx.jobs.start(p1.spec) - const p2 = producer({ owner }) - ctx.jobs.start(p2.spec) - - await agentsFiber.dispose() - p1.settle({ status: 'completed' }) - p2.settle({ status: 'failed' }) - await tick() - expect(inject).toHaveBeenCalledTimes(2) - }) }) From 053d302210a2e1d9c30c6efa0ae9530e8c8c313e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 16:30:57 +0800 Subject: [PATCH 032/141] refactor(shell tools): hand the job registry pull sources and terminal outcomes tool-bash and tool-pwsh no longer render their own background reads. processSources() wraps the process's observed offset readers as registry pull sources, pumped at dsh-jobs-local's pumpPollMs, and processOutcome() folds the settled sandbox facts into the terminal detail. observe.ts and the recordPollMs config field are gone; READMEs and pairings follow. --- packages/shell/tool-bash/README.i18n.yaml | 4 +- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-bash/src/background.ts | 67 +++++++-- packages/shell/tool-bash/src/index.ts | 36 ++--- packages/shell/tool-bash/src/observe.ts | 47 ------- packages/shell/tool-bash/src/render.ts | 34 +---- .../{observe.spec.ts => background.spec.ts} | 127 ++++++++---------- packages/shell/tool-bash/tests/tools.spec.ts | 62 ++------- packages/shell/tool-pwsh/README.i18n.yaml | 4 +- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/shell/tool-pwsh/src/background.ts | 69 ++++++++-- packages/shell/tool-pwsh/src/index.ts | 36 ++--- packages/shell/tool-pwsh/src/observe.ts | 49 ------- packages/shell/tool-pwsh/src/render.ts | 32 +---- .../{observe.spec.ts => background.spec.ts} | 71 +++++----- packages/shell/tool-pwsh/tests/tools.spec.ts | 79 +++-------- 18 files changed, 280 insertions(+), 445 deletions(-) delete mode 100644 packages/shell/tool-bash/src/observe.ts rename packages/shell/tool-bash/tests/{observe.spec.ts => background.spec.ts} (51%) delete mode 100644 packages/shell/tool-pwsh/src/observe.ts rename packages/shell/tool-pwsh/tests/{observe.spec.ts => background.spec.ts} (73%) diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index 57f2951bfa..8832f9e8ee 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-bash/README.md -README.md: 50047a5fe0d69da1feb38f75ada27595b88fa5c5 -README.zh.md: c74a45561298defb4baa5b34be7cebc1cdfbc744 +README.md: c824ee897162e8ca1b1e616f3dcace43e58f8c9a +README.zh.md: f6240153855edd2ab4eb12e8038e2108f0842b3b diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 50047a5fe0..c824ee8971 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -55,7 +55,7 @@ The tool executes `bash -c ` and returns the combined output. Commands ### Running long commands in the background -Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. The background job declares an observation record: the run's non-consuming `observed` readers are pumped into it at `recordPollMs` (default 150), so the Web client streams live output without touching the job's consuming cursor; a pump failure is logged and swallowed while the job runs on. The record is a best-effort live preview: stdout and stderr are copied per poll round, so writes the two streams made inside one `recordPollMs` window appear stdout first rather than in write order. +Passing `run_in_background: true` returns a job id immediately and no timeout applies; the command keeps running while the agent works on something else. The agent reads its output with `job_output` (non-blocking unless `wait: true`), lists jobs with `job_list`, and stops it with `job_kill`; a finished job notifies the owning agent in-session. Background support needs the generic job runtime (`dsh-jobs-local`) and its control tools (`dsh-tool-jobs`) mounted. The background job hands the run's non-consuming `observed` readers to the job registry as pull sources; the registry pumps them into the job's output ring at its own cadence (`pumpPollMs` on `dsh-jobs-local`), so the Web client streams live output and the model's `job_output` reads consume the same bytes through a separate cursor. A reader that throws is logged once and its stream stops; the job runs on to its own settlement. The ring is a best-effort live preview: stdout and stderr are copied per poll round, so writes the two streams made inside one poll window appear stdout first rather than in write order. ### Sandboxed execution and escalation diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index c74a455612..f624015385 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" ### 后台运行长时间命令 -传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。后台 job 声明观测 record:运行的非消费 `observed` 读取器以 `recordPollMs`(默认 150)的节奏泵入其中,使 Web 客户端在不触碰 job 消耗游标的情况下流式看到实时输出;泵失败会被记录并吞掉,job 继续运行。record 是尽力而为的实时预览:stdout 与 stderr 按轮询轮次复制,同一个 `recordPollMs` 窗口内两条流的写入会先 stdout 后 stderr 出现,而不是按写入顺序。 +传入 `run_in_background: true` 会立即返回 job id,不应用超时;命令继续运行,agent 同时处理其他事情。agent 用 `job_output` 读取输出(除非 `wait: true`,否则非阻塞)、用 `job_list` 列出任务、用 `job_kill` 停止任务;完成的任务会在会话内通知拥有它的 agent。后台支持需要挂载通用任务运行时(`dsh-jobs-local`)及其控制工具(`dsh-tool-jobs`)。后台 job 把运行的非消费 `observed` 读取器交给 job 注册表作为拉式源;注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)把它们泵入 job 的输出环,Web 客户端由此流式看到实时输出,模型的 `job_output` 读取则经另一个游标消费同一份字节。读取器抛错只记录一次,该流就此停止,job 继续跑到自己的结算。输出环是尽力而为的实时预览:stdout 与 stderr 按轮询轮次复制,同一个轮询窗口内两条流的写入会先 stdout 后 stderr 出现,而不是按写入顺序。 ### 沙箱执行与升权 diff --git a/packages/shell/tool-bash/src/background.ts b/packages/shell/tool-bash/src/background.ts index c01f91a586..dbe6d08c5e 100644 --- a/packages/shell/tool-bash/src/background.ts +++ b/packages/shell/tool-bash/src/background.ts @@ -1,27 +1,74 @@ /** - * Generic-task adaptation for background bash process handles. + * Generic-job adaptation for background bash process handles: the terminal + * outcome the registry records and the pull sources it pumps. * * @module @deepseek-ai/dsh-tool-bash/background */ -import type { ShellProcess } from '@deepseek-ai/dsh-shell' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' +import type { ShellProcess, ShellSandboxInfo } from '@deepseek-ai/dsh-shell' +import type { JobOutcome, JobOutputSource } from '@deepseek-ai/dsh-jobs' /** - * Map a settled background process onto the generic task-outcome vocabulary: + * Sandbox facts worth the terminal detail: a runner that never ran the + * command, or a denial (with the escalation hint this composition offers). + * @param sandbox - settled sandbox facts, when this was a confined process. + * @param escalationModes - escalation targets advertised by this composition. + * @returns the markers to append, oldest first. + */ +function sandboxNotes(sandbox: ShellSandboxInfo | undefined, escalationModes: readonly SandboxMode[]): string[] { + if (sandbox?.runnerFailed) { + return [`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`] + } + if (sandbox?.denied) { + const notes = [sandboxDenialMarker(sandbox.mode)] + if (escalationModes.length > 0) notes.push(escalationHintMarker('command')) + return notes + } + return [] +} + +/** + * Map a settled background process onto the generic job-outcome vocabulary: * `killed` stays `killed` (detail: the signal when one is known), everything * else is `completed` with the exit code as detail. A nonzero command exit is - * reported, not failed, exactly like the foreground rendering. + * reported, not failed, exactly like the foreground rendering. Sandbox facts + * join the detail, since a job's terminal reason is the one line every + * reader — the model's status line, the roster row — shows. * @param proc - the settled process handle. + * @param escalationModes - escalation targets advertised by this composition. * @returns the outcome for the `ctx.jobs` registration. */ -export function processOutcome(proc: ShellProcess): { status: 'completed' | 'killed'; detail: string } { +export function processOutcome(proc: ShellProcess, escalationModes: readonly SandboxMode[] = []): JobOutcome { // TODO(background-infrastructure-outcome): widen ShellProcess with an explicit - // infrastructure-failure outcome, then map it to task `failed`. Restricted + // infrastructure-failure outcome, then map it to job `failed`. Restricted // runner failures expose sandbox.runnerFailed, but unconfined spawn failures // still alias a signal-less kill; real nonzero command exits must remain // `completed`. - if (proc.status === 'killed') { - return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } - } - return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } + const base: JobOutcome = proc.status === 'killed' + ? { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } + : { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } + const notes = sandboxNotes(proc.sandbox, escalationModes) + return notes.length === 0 ? base : { ...base, detail: `${base.detail}; ${notes.join(' ')}` } +} + +/** + * The process's non-consuming stream readers as registry pull sources. They + * bind lazily because the process is spawned inside the starter, after the + * registry admitted the job; a backend without offset readers degrades to no + * observation rather than an error, and the pump keeps the model's consuming + * cursor untouched. + * @param proc - the started process, once the starter has spawned it. + * @returns one source per stream, stdout first. + */ +export function processSources(proc: () => ShellProcess | undefined): JobOutputSource[] { + const source = (channel: 'stdout' | 'stderr'): JobOutputSource => ({ + channel, + read: (fromByte) => { + const reader = proc()?.observed?.[channel] + return reader === undefined ? { text: '', nextOffset: fromByte, lossy: false } : reader.readFrom(fromByte) + }, + }) + return [source('stdout'), source('stderr')] } diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index c4586d0b10..8abc59a2c7 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -22,10 +22,9 @@ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandb import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' -import type { ShellRunResult } from '@deepseek-ai/dsh-shell' -import { processOutcome } from './background.ts' -import { observeProcessRecord } from './observe.ts' -import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' +import type { ShellRunResult, ShellProcess } from '@deepseek-ai/dsh-shell' +import { processOutcome, processSources } from './background.ts' +import { parseExitStatus, renderResult } from './render.ts' export const name = 'tool-bash' export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] @@ -34,18 +33,11 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ - recordPollMs?: number } /** Runtime configuration schema for the bash tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), - recordPollMs: z.number() - .step(1) - .min(1) - .max(Number.MAX_SAFE_INTEGER) - .default(150), }) /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ @@ -196,7 +188,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = { export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true - const recordPollMs = config.recordPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -369,20 +360,21 @@ export function apply(ctx: Context, config: Config = {}): void { error.name = 'AbortError' throw error } + // The registry pumps the process's non-consuming readers into the + // job's ring; the sources bind lazily because the process is spawned + // only once admission has passed. + let proc: ShellProcess | undefined const id = jobs.start({ kind: 'bash', label: args.command, - ...exec.agent ? { owner: exec.agent } : {}, - record: true, - run: (job) => { - const proc = ctx.shell.start(ctx.shell.resolve(request)) - // The pump's final drain is folded into `done` so the record holds - // its last bytes before settlement trims and closes it. - const observed = observeProcessRecord(ctx, job, proc, recordPollMs) + ...exec.agent ? { owner: exec.agent.id } : {}, + output: processSources(() => proc), + run: () => { + const started = ctx.shell.start(ctx.shell.resolve(request)) + proc = started return { - cancel: () => void proc.kill(), - done: observed.then(() => proc.done).then(() => processOutcome(proc)), - readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes), + cancel: () => void started.kill(), + done: started.done.then(() => processOutcome(started, escalationModes)), } }, }) diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts deleted file mode 100644 index 89a28817bc..0000000000 --- a/packages/shell/tool-bash/src/observe.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Record tap for background bash runs: copies the spawned process's captured - * streams into the job's observation record, so non-consuming observers (the - * Web client) see live output without touching the job's consuming - * `readOutput` cursor. Observation is strictly best-effort: absent backend - * offset readers degrade to no observation, and a pump failure is logged and - * swallowed — the job path is unaffected either way. - * The record interleaves stdout and stderr per poll round, not per write: - * output the two streams produced inside one `pollMs` window lands stdout - * first, so the panel is a live preview, not a terminal-exact transcript. - * - * @module @deepseek-ai/dsh-tool-bash/observe - */ - -import type { Context } from '@deepseek-ai/cordis' -import type { ShellProcess } from '@deepseek-ai/dsh-shell' -import { pumpJobOutput } from '@deepseek-ai/dsh-jobs' -import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs' - -/** - * Pump the process's non-consuming offset readers into the job record until - * the run settles, then drain once more. The returned promise never rejects: - * a pump failure is logged and the record simply stops advancing, while the - * process and its job continue untouched. - * @param ctx - plugin context used for the warning log. - * @param job - the running job's producer face receiving the copied chunks. - * @param proc - the started background process handle. - * @param pollMs - producer-configured pump cadence in milliseconds. - * @returns resolves after the final drain; fold into the job's `done` chain so - * the record holds its final bytes before settlement closes it. - */ -export function observeProcessRecord( - ctx: Context, - job: RecordingJob, - proc: ShellProcess, - pollMs: number, -): Promise { - const sources: JobPumpSource[] = [] - const stdout = proc.observed?.stdout - if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) - const stderr = proc.observed?.stderr - if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) - return pumpJobOutput(job, sources, { pollMs, done: proc.done }) - .catch((error: unknown) => { - ctx.logger.warn(`record observation pump for ${job.id} failed: ${String(error)}`) - }) -} diff --git a/packages/shell/tool-bash/src/render.ts b/packages/shell/tool-bash/src/render.ts index 82403cca44..a13b8348f1 100644 --- a/packages/shell/tool-bash/src/render.ts +++ b/packages/shell/tool-bash/src/render.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-tool-bash/render */ -import type { ShellProcessRead, ShellRunResult, ShellSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-shell' +import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' @@ -62,38 +62,6 @@ export function renderResult( return body + markers.join('\n') } -/** - * Shape one background-process read into the `job_output` delta the model - * sees: the incremental delta, plus the lossy-read notice (with full-stream - * spill paths) when in-memory truncation dropped unread bytes. Empty-delta - * rendering (`(no new output)`) is the generic job controller's job. - * @param read - one incremental read from the process handle. - * @param sandbox - settled sandbox facts, when this was a confined process. - * @param escalationModes - escalation targets advertised by this composition. - * @returns the delta text with any loss or sandbox notice appended. - */ -export function renderProcessRead( - read: ShellProcessRead, - sandbox?: ShellSandboxInfo, - escalationModes: readonly SandboxMode[] = [], -): string { - const notices: string[] = [] - if (read.lossy) { - const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined) - notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) - } - if (sandbox?.runnerFailed) { - notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`) - } else if (sandbox?.denied) { - notices.push(sandboxDenialMarker(sandbox.mode)) - if (escalationModes.length > 0) { - notices.push(escalationHintMarker('command')) - } - } - if (notices.length === 0) return read.delta - return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` -} - /** * The exit-status parse is the shared marker-contract half of the shell-tool * rendering story, owned by `@deepseek-ai/dsh-shell` so `dsh-tool-pwsh` reuses diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/background.spec.ts similarity index 51% rename from packages/shell/tool-bash/tests/observe.spec.ts rename to packages/shell/tool-bash/tests/background.spec.ts index 4586ada7a5..5862887118 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/background.spec.ts @@ -1,7 +1,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -10,31 +10,31 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -import type { JobId, RecordingJob } from '@deepseek-ai/dsh-jobs' +import type { JobId } from '@deepseek-ai/dsh-jobs' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import type { ShellProcess } from '@deepseek-ai/dsh-shell' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' -import { observeProcessRecord } from '../src/observe.ts' +import { processSources } from '../src/background.ts' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' const testToolSignal = new AbortController().signal -const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-observe-spec-')) +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-background-spec-')) -/** Job harness with a fast record pump for tests. */ +/** Job harness with a fast registry pump for tests. */ async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) + await ctx.plugin(LocalJobRegistry, { pumpPollMs: 25 }) await ctx.plugin(ToolTasks) await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - await ctx.plugin(ToolBash, { recordPollMs: 25 }) + await ctx.plugin(ToolBash) return ctx } @@ -42,7 +42,7 @@ let callCounter = 0 function call(ctx: Context, args: Record) { return ctx.tools.execute({ signal: testToolSignal, - callId: ToolCallId(`observe-call-${++callCounter}`), + callId: ToolCallId(`background-call-${++callCounter}`), name: 'bash', arguments: args, }) @@ -63,11 +63,11 @@ async function until(read: () => T | undefined, timeoutMs = 5_000): Promise chunk.text).join('') + return ctx.jobs.visibleTo(caller?.id).readAt(id, 0).chunks.map(chunk => chunk.text).join('') } -describe('background bash record', () => { - it('streams a background run into the job record with live output and settlement', async () => { +describe('background bash output', () => { + it('streams a background run into the job ring with live output and settlement', async () => { const ctx = await setup() const ack = await call(ctx, { command: 'printf "line-1\\n"; sleep 0.4; printf "line-2\\n"', @@ -75,34 +75,33 @@ describe('background bash record', () => { run_in_background: true, }) expect(text(ack)).toContain('started background job') - const job = ctx.jobs.list()[0] + const jobs = ctx.jobs.visibleTo() + const job = jobs.list()[0] expect(job).toBeDefined() - expect(job!.outputTotal).toBeDefined() - // Live output appears in the record while the command is still running. + // Live output appears in the ring while the command is still running. await until(() => retainedText(ctx, job!.id).includes('line-1') ? true : undefined) - expect(ctx.jobs.get(job!.id).status).toBe('running') + expect(jobs.get(job!.id).status).toBe('running') - // Settlement ends the record with the job; the trailing bytes are drained first. - await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) - expect(ctx.jobs.get(job!.id).detail).toBe('exit code: 0') + // Settlement ends the ring with the job; the trailing bytes are drained first. + await until(() => jobs.get(job!.id).status === 'completed' ? true : undefined) + expect(jobs.get(job!.id).detail).toBe('exit code: 0') expect(retainedText(ctx, job!.id)).toContain('line-2') - // The model-facing consuming cursor still delivers everything: observation stole nothing. - const collected = await until(() => { - const read = ctx.jobs.read(job!.id) - return read.text.includes('line-1') ? read.text : undefined - }) - expect(collected).toContain('line-2') + // The model-facing consuming cursor reads the same bytes: observation stole nothing. + const consumed = jobs.read(job!.id).chunks.map(chunk => chunk.text).join('') + expect(consumed).toContain('line-1') + expect(consumed).toContain('line-2') }) - it('a killed background job ends its record with the killed settlement', async () => { + it('a killed background job settles killed with the kill reason merged into its detail', async () => { const ctx = await setup() await call(ctx, { command: 'sleep 60', description: 'test command', run_in_background: true }) - const job = ctx.jobs.list()[0] - ctx.jobs.kill(job!.id, undefined, 'test cleanup') - await until(() => ctx.jobs.get(job!.id).status === 'killed' ? true : undefined) - expect(ctx.jobs.get(job!.id).detail).toMatch(/signal|killed before exit/) + const jobs = ctx.jobs.visibleTo() + const job = jobs.list()[0] + jobs.kill(job!.id, { reason: 'test cleanup' }) + await until(() => jobs.get(job!.id).status === 'killed' ? true : undefined) + expect(jobs.get(job!.id).detail).toMatch(/(signal|killed before exit).*; test cleanup$/) }) it('labels stderr chunks with their channel', async () => { @@ -112,73 +111,63 @@ describe('background bash record', () => { description: 'test command', run_in_background: true, }) - const job = ctx.jobs.list()[0] - await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) - const chunks = ctx.jobs.readRecord(job!.id, 0).chunks + const jobs = ctx.jobs.visibleTo() + const job = jobs.list()[0] + await until(() => jobs.get(job!.id).status === 'completed' ? true : undefined) + const chunks = jobs.readAt(job!.id, 0).chunks expect(chunks.find(chunk => chunk.text.includes('out-line'))?.channel).toBe('stdout') expect(chunks.find(chunk => chunk.text.includes('err-line'))?.channel).toBe('stderr') }) }) -describe('observeProcessRecord', () => { - /** A RecordingJob face recording appends for pump assertions. */ - function recordingJob(): { job: RecordingJob; appends: string[] } { - const appends: string[] = [] - return { - job: { - id: 'bash-1' as JobId, - append(chunk) { appends.push(chunk) }, - updateDetail() {}, - }, - appends, - } - } - - it('degrades to no observation when the backend exposes no offset readers', async () => { - const ctx = new Context() - const { job, appends } = recordingJob() - const proc = { done: Promise.resolve() } as unknown as ShellProcess - await observeProcessRecord(ctx, job, proc, 25) - expect(appends).toEqual([]) +describe('processSources', () => { + it('reads nothing while the process is not spawned yet and when the backend exposes no readers', () => { + const spawned: { proc?: ShellProcess } = {} + const [stdout, stderr] = processSources(() => spawned.proc) + expect(stdout!.channel).toBe('stdout') + expect(stderr!.channel).toBe('stderr') + expect(stdout!.read(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) + spawned.proc = { done: Promise.resolve() } as unknown as ShellProcess + expect(stderr!.read(3)).toEqual({ text: '', nextOffset: 3, lossy: false }) }) - it('contains a pump failure with a warning; the job path is unaffected', async () => { - const ctx = new Context() - const warn = vi.fn() - ctx.logger.warn = warn as never - const { job } = recordingJob() + it('forwards each read to the matching stream reader once the process exists', () => { + const reads: number[] = [] const proc = { - done: new Promise((resolve) => { setTimeout(resolve, 10) }), observed: { - stdout: { readFrom() { throw new Error('reader boom') } }, + stdout: { readFrom: (from: number) => { reads.push(from); return { text: 'out', nextOffset: from + 3, lossy: false } } }, }, } as unknown as ShellProcess - await observeProcessRecord(ctx, job, proc, 5) - expect(warn).toHaveBeenCalledWith(expect.stringContaining('record observation pump for bash-1 failed')) + const [stdout, stderr] = processSources(() => proc) + expect(stdout!.read(2)).toEqual({ text: 'out', nextOffset: 5, lossy: false }) + expect(reads).toEqual([2]) + // A missing stderr reader is an absent stream, not an error. + expect(stderr!.read(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) }) }) -describe('owned background record', () => { - it('fences an owned background run record under the owning session', async () => { +describe('owned background output', () => { + it('fences an owned background run under the owning session', async () => { const ctx = await setup() const owner = { - id: SessionId('observe-owner'), - session: { id: SessionId('observe-owner'), header: { cwd: process.cwd() } }, + id: SessionId('background-owner'), + session: { id: SessionId('background-owner'), header: { cwd: process.cwd() } }, status: 'idle', ctx, } as unknown as Agent ctx.agents.register(owner) await ctx.tools.execute({ signal: testToolSignal, - callId: ToolCallId('observe-owned-1'), + callId: ToolCallId('background-owned-1'), name: 'bash', arguments: { command: 'echo owned', description: 'test command', run_in_background: true }, agent: owner, }) - const job = ctx.jobs.list(owner)[0] + const owned = ctx.jobs.visibleTo(owner.id) + const job = owned.list()[0] expect(job).toBeDefined() - expect(job!.ownerSession).toBe(owner.id) - await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined) + expect(job!.owner).toBe(owner.id) + await until(() => owned.get(job!.id).status === 'completed' ? true : undefined) expect(() => retainedText(ctx, job!.id)).toThrow(/belongs to another session/) await until(() => retainedText(ctx, job!.id, owner).includes('owned') ? true : undefined) }) diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 747d98b48d..8a4d7a41b4 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { ShellExecutor } from '@deepseek-ai/dsh-shell' -import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellProcessRead, ShellRunResult } from '@deepseek-ai/dsh-shell' +import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -22,8 +22,9 @@ import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' +import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' import { processOutcome } from '../src/background.ts' -import { renderProcessRead, renderResult } from '../src/render.ts' +import { renderResult } from '../src/render.ts' const testToolSignal = new AbortController().signal @@ -731,53 +732,6 @@ describe('sandbox escalation through the generic task producer', () => { }) }) -describe('renderProcessRead', () => { - const base: ShellProcessRead = { delta: 'out\n', lossy: false } - - it('returns the delta verbatim for a lossless read', () => { - expect(renderProcessRead(base)).toBe('out\n') - expect(renderProcessRead({ delta: '', lossy: false })).toBe('') - }) - - it('appends the loss notice with the available spill paths', () => { - expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log' })) - .toBe('out\n[some output was dropped from memory; full output: /spill/out.log]') - expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log', stderrSpillPath: '/spill/err.log' })) - .toBe('out\n[some output was dropped from memory; full output: /spill/out.log, /spill/err.log]') - }) - - it('reports (unavailable) when a lossy read has no safe spill path', () => { - expect(renderProcessRead({ ...base, lossy: true })) - .toBe('out\n[some output was dropped from memory; full output: (unavailable)]') - }) - - it('an empty lossy delta is the notice alone', () => { - expect(renderProcessRead({ delta: '', lossy: true, stderrSpillPath: '/spill/err.log' })) - .toBe('[some output was dropped from memory; full output: /spill/err.log]') - }) - - it('inserts the separating newline only when the delta lacks one', () => { - expect(renderProcessRead({ delta: 'tail', lossy: true })) - .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') - expect(renderProcessRead({ delta: 'tail\n', lossy: true })) - .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') - }) - - it('appends settled sandbox denial and runner-failure facts', () => { - expect(renderProcessRead(base, { mode: 'read-only', denied: true }, ['workspace-write'])) - .toContain('[sandbox: escalation available') - expect(renderProcessRead({ delta: 'tail', lossy: false }, { mode: 'read-only', denied: true })) - .toBe('tail\n[sandbox: file access denied under read-only mode]') - const runner = renderProcessRead( - { delta: '', lossy: false }, - { mode: 'workspace-write', denied: true, runnerFailed: true }, - ['danger-full-access'], - ) - expect(runner).toContain('sandbox runner itself failed under workspace-write mode') - expect(runner).not.toContain('file access denied') - }) -}) - describe('processOutcome', () => { function settled(over: Partial): ShellProcess { return { @@ -810,6 +764,16 @@ describe('processOutcome', () => { expect(processOutcome(settled({ exitCode: null }))) .toEqual({ status: 'completed', detail: 'exit code: 0' }) }) + + it('appends sandbox facts to the terminal detail', () => { + const denied = processOutcome(settled({ exitCode: 1, sandbox: { mode: 'read-only', denied: true } }), ['workspace-write']) + expect(denied.detail).toBe(`exit code: 1; ${sandboxDenialMarker('read-only')} ${escalationHintMarker('command')}`) + const deniedWithoutEscalation = processOutcome(settled({ exitCode: 1, sandbox: { mode: 'read-only', denied: true } })) + expect(deniedWithoutEscalation.detail).toBe(`exit code: 1; ${sandboxDenialMarker('read-only')}`) + const runnerFailed = processOutcome(settled({ exitCode: 1, sandbox: { mode: 'read-only', denied: false, runnerFailed: true } })) + expect(runnerFailed.detail).toContain('the sandbox runner itself failed under read-only mode') + expect(processOutcome(settled({ sandbox: { mode: 'read-only', denied: false } })).detail).toBe('exit code: 0') + }) }) describe('session-cwd routing (per-session workdir)', () => { diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index fbf8f4b9ae..ff3bcb8f9b 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-pwsh/README.md -README.md: 6ce58c103e0e11c5011632e6c4a7024deeaa79cd -README.zh.md: b583cf3f9198ed9dd01a239405a6ada1094ecb9f +README.md: ead65fb453dfbcf36307ebda00d79e2e08c58744 +README.zh.md: 8b92fffe3797a096c6c292c3f277812a31020048 diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index 6ce58c103e..ead65fb453 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-tool-pwsh` gives the agent a `pwsh` tool that runs PowerShell commands through the mounted shell executor — the Windows counterpart of `dsh-tool-bash`, mirroring it call-for-call. Each call runs in a fresh pwsh process, so no state survives; `run_in_background` turns long-running commands into background jobs. Commands are PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables, with no dialect translation. Every call runs with the managed `DSH_*` environment, and under a sandboxing executor the tool teaches and enforces the Windows-specific language-mode and named-pipe contracts. Mount it with a PowerShell executor such as `dsh-pwsh-local` and the `dsh-shell-env` plugin. A background run's job declares an observation record pumped at `recordPollMs` (default 150), exactly like `dsh-tool-bash`. +`dsh-tool-pwsh` gives the agent a `pwsh` tool that runs PowerShell commands through the mounted shell executor — the Windows counterpart of `dsh-tool-bash`, mirroring it call-for-call. Each call runs in a fresh pwsh process, so no state survives; `run_in_background` turns long-running commands into background jobs. Commands are PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables, with no dialect translation. Every call runs with the managed `DSH_*` environment, and under a sandboxing executor the tool teaches and enforces the Windows-specific language-mode and named-pipe contracts. Mount it with a PowerShell executor such as `dsh-pwsh-local` and the `dsh-shell-env` plugin. A background run hands the process's offset readers to the job registry as pull sources, pumped at `pumpPollMs` on `dsh-jobs-local`, exactly like `dsh-tool-bash`. ## Table of Contents diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index b583cf3f91..8b92fffe37 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-tool-pwsh` 为 agent 提供 `pwsh` 工具,通过已挂载的 shell 执行器运行 PowerShell 命令——它是 `dsh-tool-bash` 的 Windows 对应物,逐调用镜像。每次调用都运行在全新 pwsh 进程中,因此状态不会保留;`run_in_background` 把长时间运行的命令变成后台任务。命令是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量,不做方言翻译。每次调用都运行在受管 `DSH_*` 环境中;在沙箱执行器下,工具会教授并执行 Windows 特有的语言模式与命名管道约定。请与 `dsh-pwsh-local` 等 PowerShell 执行器以及 `dsh-shell-env` 插件一起挂载。后台运行的 job 声明按 `recordPollMs`(默认 150)泵送的观测 record,与 `dsh-tool-bash` 完全一致。 +`dsh-tool-pwsh` 为 agent 提供 `pwsh` 工具,通过已挂载的 shell 执行器运行 PowerShell 命令——它是 `dsh-tool-bash` 的 Windows 对应物,逐调用镜像。每次调用都运行在全新 pwsh 进程中,因此状态不会保留;`run_in_background` 把长时间运行的命令变成后台任务。命令是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量,不做方言翻译。每次调用都运行在受管 `DSH_*` 环境中;在沙箱执行器下,工具会教授并执行 Windows 特有的语言模式与命名管道约定。请与 `dsh-pwsh-local` 等 PowerShell 执行器以及 `dsh-shell-env` 插件一起挂载。后台运行把进程的偏移读取器作为拉取源交给 job 注册表,由 `dsh-jobs-local` 按其 `pumpPollMs` 泵送,与 `dsh-tool-bash` 完全一致。 ## 目录 diff --git a/packages/shell/tool-pwsh/src/background.ts b/packages/shell/tool-pwsh/src/background.ts index dc0f53ffab..81066ecb0b 100644 --- a/packages/shell/tool-pwsh/src/background.ts +++ b/packages/shell/tool-pwsh/src/background.ts @@ -1,31 +1,78 @@ /** - * Generic-task adaptation for background pwsh process handles — the shell-agnostic - * twin of `dsh-tool-bash`'s background adaptation. + * Generic-job adaptation for background pwsh process handles — the + * shell-agnostic twin of `dsh-tool-bash`'s background adaptation: the terminal + * outcome the registry records and the pull sources it pumps. * * @module @deepseek-ai/dsh-tool-pwsh/background */ -import type { ShellProcess } from '@deepseek-ai/dsh-shell' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' +import type { ShellProcess, ShellSandboxInfo } from '@deepseek-ai/dsh-shell' +import type { JobOutcome, JobOutputSource } from '@deepseek-ai/dsh-jobs' /* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */ /** - * Map a settled background process onto the generic task-outcome vocabulary: + * Sandbox facts worth the terminal detail: a runner that never ran the + * command, or a denial (with the escalation hint this composition offers). + * @param sandbox - settled sandbox facts, when this was a confined process. + * @param escalationModes - escalation targets advertised by this composition. + * @returns the markers to append, oldest first. + */ +function sandboxNotes(sandbox: ShellSandboxInfo | undefined, escalationModes: readonly SandboxMode[]): string[] { + if (sandbox?.runnerFailed) { + return [`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`] + } + if (sandbox?.denied) { + const notes = [sandboxDenialMarker(sandbox.mode)] + if (escalationModes.length > 0) notes.push(escalationHintMarker('command')) + return notes + } + return [] +} + +/** + * Map a settled background process onto the generic job-outcome vocabulary: * `killed` stays `killed` (detail: the signal when one is known), everything * else is `completed` with the exit code as detail. A nonzero command exit is - * reported, not failed, exactly like the foreground rendering. + * reported, not failed, exactly like the foreground rendering. Sandbox facts + * join the detail, since a job's terminal reason is the one line every + * reader — the model's status line, the roster row — shows. * @param proc - the settled process handle. + * @param escalationModes - escalation targets advertised by this composition. * @returns the outcome for the `ctx.jobs` registration. */ -export function processOutcome(proc: ShellProcess): { status: 'completed' | 'killed'; detail: string } { +export function processOutcome(proc: ShellProcess, escalationModes: readonly SandboxMode[] = []): JobOutcome { // TODO(background-infrastructure-outcome): widen ShellProcess with an explicit // infrastructure-failure outcome, then map spawn failures and - // sandbox.runnerFailed to task `failed`. The current contract aliases a spawn + // sandbox.runnerFailed to job `failed`. The current contract aliases a spawn // failure with a signal-less kill and a runner failure with an ordinary // wrapper exit; real nonzero command exits must remain `completed`. - if (proc.status === 'killed') { - return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } - } - return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } + const base: JobOutcome = proc.status === 'killed' + ? { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } + : { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } + const notes = sandboxNotes(proc.sandbox, escalationModes) + return notes.length === 0 ? base : { ...base, detail: `${base.detail}; ${notes.join(' ')}` } +} + +/** + * The process's non-consuming stream readers as registry pull sources. They + * bind lazily because the process is spawned inside the starter, after the + * registry admitted the job; a backend without offset readers degrades to no + * observation rather than an error, and the pump keeps the model's consuming + * cursor untouched. + * @param proc - the started process, once the starter has spawned it. + * @returns one source per stream, stdout first. + */ +export function processSources(proc: () => ShellProcess | undefined): JobOutputSource[] { + const source = (channel: 'stdout' | 'stderr'): JobOutputSource => ({ + channel, + read: (fromByte) => { + const reader = proc()?.observed?.[channel] + return reader === undefined ? { text: '', nextOffset: fromByte, lossy: false } : reader.readFrom(fromByte) + }, + }) + return [source('stdout'), source('stderr')] } /* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index a7926e1b34..b84f618a33 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -32,11 +32,10 @@ import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' -import type { ShellRunResult } from '@deepseek-ai/dsh-shell' +import type { ShellRunResult, ShellProcess } from '@deepseek-ai/dsh-shell' import { parseExitStatus } from '@deepseek-ai/dsh-shell' -import { processOutcome } from './background.ts' -import { observeProcessRecord } from './observe.ts' -import { renderPwshProcessRead, renderPwshResult } from './render.ts' +import { processOutcome, processSources } from './background.ts' +import { renderPwshResult } from './render.ts' import type { RenderablePwshResult } from './render.ts' declare module '@deepseek-ai/dsh-jobs' { @@ -53,18 +52,11 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ - recordPollMs?: number } /** Runtime configuration schema for the pwsh tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), - recordPollMs: z.number() - .step(1) - .min(1) - .max(Number.MAX_SAFE_INTEGER) - .default(150), }) /* jscpd:ignore-end */ @@ -203,7 +195,6 @@ const BACKGROUND_OUTPUT_PROPERTIES = { /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */ export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true - const recordPollMs = config.recordPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -387,20 +378,21 @@ export function apply(ctx: Context, config: Config = {}): void { error.name = 'AbortError' throw error } + // The registry pumps the process's non-consuming readers into the + // job's ring; the sources bind lazily because the process is spawned + // only once admission has passed. + let proc: ShellProcess | undefined const id = jobs.start({ kind: 'pwsh', label: args.command, - ...exec.agent ? { owner: exec.agent } : {}, - record: true, - run: (job) => { - const proc = ctx.shell.start(ctx.shell.resolve(request)) - // The pump's final drain is folded into `done` so the record holds - // its last bytes before settlement trims and closes it. - const observed = observeProcessRecord(ctx, job, proc, recordPollMs) + ...exec.agent ? { owner: exec.agent.id } : {}, + output: processSources(() => proc), + run: () => { + const started = ctx.shell.start(ctx.shell.resolve(request)) + proc = started return { - cancel: () => void proc.kill(), - done: observed.then(() => proc.done).then(() => processOutcome(proc)), - readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes), + cancel: () => void started.kill(), + done: started.done.then(() => processOutcome(started, escalationModes)), } }, }) diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts deleted file mode 100644 index 357567e31e..0000000000 --- a/packages/shell/tool-pwsh/src/observe.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Record tap for background pwsh runs: copies the spawned process's captured - * streams into the job's observation record, so non-consuming observers (the - * Web client) see live output without touching the job's consuming - * `readOutput` cursor. Observation is strictly best-effort: absent backend - * offset readers degrade to no observation, and a pump failure is logged and - * swallowed — the job path is unaffected either way. - * The record interleaves stdout and stderr per poll round, not per write: - * output the two streams produced inside one `pollMs` window lands stdout - * first, so the panel is a live preview, not a terminal-exact transcript. - * - * @module @deepseek-ai/dsh-tool-pwsh/observe - */ - -/* jscpd:ignore-start -- mirrors tool-bash's observe tap by design, like render/background. */ -import type { Context } from '@deepseek-ai/cordis' -import type { ShellProcess } from '@deepseek-ai/dsh-shell' -import { pumpJobOutput } from '@deepseek-ai/dsh-jobs' -import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs' - -/** - * Pump the process's non-consuming offset readers into the job record until - * the run settles, then drain once more. The returned promise never rejects: - * a pump failure is logged and the record simply stops advancing, while the - * process and its job continue untouched. - * @param ctx - plugin context used for the warning log. - * @param job - the running job's producer face receiving the copied chunks. - * @param proc - the started background process handle. - * @param pollMs - producer-configured pump cadence in milliseconds. - * @returns resolves after the final drain; fold into the job's `done` chain so - * the record holds its final bytes before settlement closes it. - */ -export function observeProcessRecord( - ctx: Context, - job: RecordingJob, - proc: ShellProcess, - pollMs: number, -): Promise { - const sources: JobPumpSource[] = [] - const stdout = proc.observed?.stdout - if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) - const stderr = proc.observed?.stderr - if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) - return pumpJobOutput(job, sources, { pollMs, done: proc.done }) - .catch((error: unknown) => { - ctx.logger.warn(`record observation pump for ${job.id} failed: ${String(error)}`) - }) -} -/* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/src/render.ts b/packages/shell/tool-pwsh/src/render.ts index cc7b8a661d..01e0a51e2e 100644 --- a/packages/shell/tool-pwsh/src/render.ts +++ b/packages/shell/tool-pwsh/src/render.ts @@ -10,7 +10,7 @@ * @module @deepseek-ai/dsh-tool-pwsh/render */ -import type { ShellProcessRead, ShellSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-shell' +import type { ShellSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-shell' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' @@ -80,34 +80,4 @@ export function renderPwshResult( return body + markers.join('\n') } -/** - * Shape one background-process read into the `job_output` delta the model - * sees: the incremental delta, plus the lossy-read notice (with full-stream - * spill paths) when in-memory truncation dropped unread bytes. - * @param read - one incremental read from the process handle. - * @param sandbox - settled sandbox facts, when this was a confined process. - * @param escalationModes - escalation targets advertised by this composition. - * @returns the delta text with any loss or sandbox notice appended. - */ -export function renderPwshProcessRead( - read: ShellProcessRead, - sandbox?: ShellSandboxInfo, - escalationModes: readonly SandboxMode[] = [], -): string { - const notices: string[] = [] - if (read.lossy) { - const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined) - notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) - } - if (sandbox?.runnerFailed) { - notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`) - } else if (sandbox?.denied) { - notices.push(sandboxDenialMarker(sandbox.mode)) - if (escalationModes.length > 0) { - notices.push(escalationHintMarker('command')) - } - } - if (notices.length === 0) return read.delta - return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` -} /* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/background.spec.ts similarity index 73% rename from packages/shell/tool-pwsh/tests/observe.spec.ts rename to packages/shell/tool-pwsh/tests/background.spec.ts index 0fbdcb80de..6c12ff8611 100644 --- a/packages/shell/tool-pwsh/tests/observe.spec.ts +++ b/packages/shell/tool-pwsh/tests/background.spec.ts @@ -78,11 +78,11 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) + await ctx.plugin(LocalJobRegistry, { pumpPollMs: 5 }) await ctx.plugin(ToolTasks) await ctx.plugin(BashEnvPlugin) await ctx.plugin(FakePwsh) - await ctx.plugin(ToolPwsh, { recordPollMs: 5 }) + await ctx.plugin(ToolPwsh) return { ctx, pwsh: ctx.shell as FakePwsh } } @@ -90,7 +90,7 @@ let callCounter = 0 function call(ctx: Context, args: Record) { return ctx.tools.execute({ signal: testToolSignal, - callId: ToolCallId(`pwsh-observe-${++callCounter}`), + callId: ToolCallId(`pwsh-background-${++callCounter}`), name: 'pwsh', arguments: args, }) @@ -106,8 +106,8 @@ async function until(read: () => T | undefined, timeoutMs = 5_000): Promise { - it('streams observed channels into the job record and settles with the mapped outcome', async () => { +describe('background pwsh output', () => { + it('streams observed channels into the job ring and settles with the mapped outcome', async () => { const { ctx, pwsh } = await setup() const stdout = { text: '' } const stderr = { text: '' } @@ -115,30 +115,31 @@ describe('background pwsh record', () => { pwsh.backgroundHandler = () => scripted.proc await call(ctx, { command: 'Get-Progress', description: 'test command', run_in_background: true }) - const job = ctx.jobs.list()[0] + const jobs = ctx.jobs.visibleTo() + const job = jobs.list()[0] expect(job).toBeDefined() expect(job!.kind).toBe('pwsh') - expect(job!.outputTotal).toBe(0) + expect(job!.output).toEqual({ total: 0, earliest: 0 }) stdout.text = 'progress-line\n' stderr.text = 'warn-line\n' await until(() => { - const chunks = ctx.jobs.readRecord(job!.id, 0).chunks + const chunks = jobs.readAt(job!.id, 0).chunks return chunks.some(chunk => chunk.text.includes('progress-line')) && chunks.some(chunk => chunk.text.includes('warn-line')) ? true : undefined }) - const chunks = ctx.jobs.readRecord(job!.id, 0).chunks + const chunks = jobs.readAt(job!.id, 0).chunks expect(chunks.find(chunk => chunk.text.includes('progress-line'))?.channel).toBe('stdout') expect(chunks.find(chunk => chunk.text.includes('warn-line'))?.channel).toBe('stderr') scripted.finish() - await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) - expect(ctx.jobs.get(job!.id).detail).toBe('exit code: 0') + await until(() => jobs.get(job!.id).status === 'completed' ? true : undefined) + expect(jobs.get(job!.id).detail).toBe('exit code: 0') }) - it('a pump failure warns and never fakes a terminal state onto the running job', async () => { + it('a failing reader warns once and never fakes a terminal state onto the running job', async () => { const { ctx, pwsh } = await setup() const warn = vi.fn() ctx.logger.warn = warn as never @@ -158,40 +159,43 @@ describe('background pwsh record', () => { } pwsh.backgroundHandler = () => proc await call(ctx, { command: 'Get-Broken', description: 'test command', run_in_background: true }) - const job = ctx.jobs.list()[0] - await until(() => warn.mock.calls.some(args => String(args[0]).includes('record observation pump')) ? true : undefined) - // The pump already failed; the job must wait for real settlement rather + const jobs = ctx.jobs.visibleTo() + const job = jobs.list()[0] + await until(() => warn.mock.calls.some(args => String(args[0]).includes('output source for')) ? true : undefined) + // The reader already failed; the job must wait for real settlement rather // than freezing a fake terminal state onto the running process. await new Promise(resolve => setTimeout(resolve, 50)) - expect(ctx.jobs.get(job!.id).status).toBe('running') + expect(jobs.get(job!.id).status).toBe('running') + expect(warn).toHaveBeenCalledTimes(1) proc.status = 'completed' proc.exitCode = 0 resolveDone() - await until(() => ctx.jobs.get(job!.id).status !== 'running' ? true : undefined) - expect(ctx.jobs.get(job!.id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) + await until(() => jobs.get(job!.id).status !== 'running' ? true : undefined) + expect(jobs.get(job!.id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) }) - it('a backend without observed readers still yields a status-only record', async () => { + it('a backend without observed readers still settles with an empty ring', async () => { const { ctx, pwsh } = await setup() const scripted = observableProcess() pwsh.backgroundHandler = () => scripted.proc await call(ctx, { command: 'Start-Job', description: 'test command', run_in_background: true }) - const job = ctx.jobs.list()[0] - expect(ctx.jobs.readRecord(job!.id, 0).chunks).toEqual([]) + const jobs = ctx.jobs.visibleTo() + const job = jobs.list()[0] + expect(jobs.readAt(job!.id, 0).chunks).toEqual([]) scripted.finish() - await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) - expect(ctx.jobs.get(job!.id).outputTotal).toBe(0) + await until(() => jobs.get(job!.id).status === 'completed' ? true : undefined) + expect(jobs.get(job!.id).output).toEqual({ total: 0, earliest: 0 }) }) }) -describe('owned background record (pwsh)', () => { - it('keeps an owned background run record under the owning session', async () => { +describe('owned background output (pwsh)', () => { + it('keeps an owned background run under the owning session', async () => { const { ctx, pwsh } = await setup() const owner = { - id: SessionId('pwsh-observe-owner'), - session: { id: SessionId('pwsh-observe-owner'), header: { cwd: process.cwd() } }, + id: SessionId('pwsh-background-owner'), + session: { id: SessionId('pwsh-background-owner'), header: { cwd: process.cwd() } }, status: 'idle', ctx, } as unknown as Agent @@ -200,17 +204,18 @@ describe('owned background record (pwsh)', () => { pwsh.backgroundHandler = () => scripted.proc await ctx.tools.execute({ signal: testToolSignal, - callId: ToolCallId('pwsh-observe-owned'), + callId: ToolCallId('pwsh-background-owned'), name: 'pwsh', arguments: { command: 'Get-Slow', description: 'test command', run_in_background: true }, agent: owner, }) - const job = ctx.jobs.list(owner)[0] + const owned = ctx.jobs.visibleTo(owner.id) + const job = owned.list()[0] expect(job).toBeDefined() - expect(job!.ownerSession).toBe(owner.id) - expect(() => ctx.jobs.readRecord(job!.id, 0)).toThrow(/belongs to another session/) - expect(ctx.jobs.readRecord(job!.id, 0, owner).chunks).toEqual([]) + expect(job!.owner).toBe(owner.id) + expect(() => ctx.jobs.visibleTo().readAt(job!.id, 0)).toThrow(/belongs to another session/) + expect(owned.readAt(job!.id, 0).chunks).toEqual([]) scripted.finish() - await until(() => ctx.jobs.get(job!.id, owner).status === 'completed' ? true : undefined) + await until(() => owned.get(job!.id).status === 'completed' ? true : undefined) }) }) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 5e27e3c8e0..0995697129 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -32,9 +32,9 @@ import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' -import type { ShellProcessRead } from '@deepseek-ai/dsh-shell' +import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' import { processOutcome } from '../src/background.ts' -import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts' +import { renderPwshResult } from '../src/render.ts' const testToolSignal = new AbortController().signal @@ -91,20 +91,19 @@ function runResult(stdout: string, overrides?: Partial): ShellRu } } -/** A settled successful background handle; overrides script failure shapes. */ +/** A settled successful background handle whose stdout the registry pump reads by offset (ASCII: offsets are string indexes). */ function fakeProcess(delta = 'bg-ok\n'): ShellProcess { - let consumed = false return { status: 'completed', exitCode: 0, signal: null, done: Promise.resolve(), - readOutput: () => { - if (consumed) return { delta: '', lossy: false } - consumed = true - return { delta, lossy: false } - }, + readOutput: () => ({ delta: '', lossy: false }), kill: () => false, + observed: { + stdout: { readFrom: (fromByte: number) => ({ text: delta.slice(fromByte), nextOffset: delta.length, lossy: false }) }, + stderr: { readFrom: (fromByte: number) => ({ text: '', nextOffset: fromByte, lossy: false }) }, + }, } } @@ -1005,58 +1004,6 @@ describe('renderPwshResult sandbox markers', () => { }) }) -describe('renderPwshProcessRead', () => { - const base: ShellProcessRead = { delta: 'out\n', lossy: false } - - it('returns the delta verbatim for a lossless read', () => { - expect(renderPwshProcessRead(base)).toBe('out\n') - expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('') - }) - - it('appends the loss notice with the available spill paths', () => { - expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' })) - .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]') - expect(renderPwshProcessRead({ - ...base, - lossy: true, - stdoutSpillPath: 'C:\\spill\\out.log', - stderrSpillPath: 'C:\\spill\\err.log', - })) - .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]') - }) - - it('reports (unavailable) when a lossy read has no safe spill path', () => { - expect(renderPwshProcessRead({ ...base, lossy: true })) - .toBe('out\n[some output was dropped from memory; full output: (unavailable)]') - }) - - it('an empty lossy delta is the notice alone', () => { - expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' })) - .toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]') - }) - - it('inserts the separating newline only when the delta lacks one', () => { - expect(renderPwshProcessRead({ delta: 'tail', lossy: true })) - .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') - expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true })) - .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') - }) - - it('appends the runner-failed notice (denial outranked)', () => { - expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true, runnerFailed: true })) - .toBe('x\n[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; this is a sandbox problem, not a command failure]') - }) - - it('appends the denial marker and hints only when escalation is advertised', () => { - expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true })) - .toBe('x\n[sandbox: file access denied under read-only mode]') - expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }, ['workspace-write'])) - .toBe('x\n[sandbox: file access denied under read-only mode]\n' - + '[sandbox: escalation available — retry this exact command once with sandbox_permissions ' - + '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') - }) -}) - describe('processOutcome', () => { function settled(over: Partial): ShellProcess { return { @@ -1089,4 +1036,14 @@ describe('processOutcome', () => { expect(processOutcome(settled({ exitCode: null }))) .toEqual({ status: 'completed', detail: 'exit code: 0' }) }) + + it('appends sandbox facts to the terminal detail', () => { + const denied = processOutcome(settled({ exitCode: 1, sandbox: { mode: 'read-only', denied: true } }), ['workspace-write']) + expect(denied.detail).toBe(`exit code: 1; ${sandboxDenialMarker('read-only')} ${escalationHintMarker('command')}`) + const deniedWithoutEscalation = processOutcome(settled({ exitCode: 1, sandbox: { mode: 'read-only', denied: true } })) + expect(deniedWithoutEscalation.detail).toBe(`exit code: 1; ${sandboxDenialMarker('read-only')}`) + const runnerFailed = processOutcome(settled({ exitCode: 1, sandbox: { mode: 'read-only', denied: false, runnerFailed: true } })) + expect(runnerFailed.detail).toContain('the sandbox runner itself failed under read-only mode') + expect(processOutcome(settled({ sandbox: { mode: 'read-only', denied: false } })).detail).toBe('exit code: 0') + }) }) From 10d76fbd03ee00cd478ef86fbcc3c75c2686ab0a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 16:37:35 +0800 Subject: [PATCH 033/141] refactor(producers): subagent and terminal jobs use session owners, results, and pull sources - tool-subagent registers the parent session id as the owner and settleRun reports the child's final text as the job result, which the first read after settlement carries once - tool-terminal hands the registry one pull source over the backend's consuming send reader (sendSource in background.ts) instead of a readOutput hook; the truncation marker stays in the delivered text - the model's kill reason now shows in a killed job's status line, as the registry records it as the terminal detail - test call sites move from jobs.list(owner) to visibleTo(owner).list() --- .../tests/profiles/headless/tests/ptc.e2e.ts | 10 +++--- .../subagent/subagent/src/run-settlement.ts | 2 +- .../subagent/tests/run-settlement.spec.ts | 4 +-- packages/subagent/tool-subagent/src/index.ts | 4 +-- .../tool-subagent/tests/tool-subagent.spec.ts | 15 ++++----- .../terminal/tool-terminal/src/background.ts | 31 +++++++++++++++++++ packages/terminal/tool-terminal/src/index.ts | 17 +++++----- .../tool-terminal/tests/tools.spec.ts | 22 +++++++++++-- 8 files changed, 79 insertions(+), 26 deletions(-) create mode 100644 packages/terminal/tool-terminal/src/background.ts diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 8ee3affb8a..25a7292e45 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -222,7 +222,7 @@ describe('PTC mode typed values: keyless real-worker contracts', () => { return await tools.bash({ command: 'sleep 10', description: 'Must never start', run_in_background: true }); `, pre.signal) expect(preResult.isError).toBe(true) - expect(ctx.jobs.list()).toEqual([]) + expect(ctx.jobs.visibleTo().list()).toEqual([]) const afterPublication = new AbortController() const running = runCode(ctx, ` @@ -230,14 +230,14 @@ describe('PTC mode typed values: keyless real-worker contracts', () => { console.log(started.jobId); await new Promise(() => {}); `, afterPublication.signal) - for (let attempt = 0; attempt < 100 && ctx.jobs.list().length === 0; attempt++) { + for (let attempt = 0; attempt < 100 && ctx.jobs.visibleTo().list().length === 0; attempt++) { await new Promise(resolve => setTimeout(resolve, 10)) } - const job = ctx.jobs.list()[0] + const job = ctx.jobs.visibleTo().list()[0] expect(job).toMatchObject({ id: 'bash-1', status: 'running' }) afterPublication.abort('outer-call-cancelled') expect((await running).isError).toBe(true) - expect(ctx.jobs.list()[0]).toMatchObject({ id: job!.id, status: 'running' }) + expect(ctx.jobs.visibleTo().list()[0]).toMatchObject({ id: job!.id, status: 'running' }) const killed = completion(await runCode(ctx, ` return await tools.job_kill({ job_id: ${JSON.stringify(job!.id)}, reason: 'test owns cancellation' }); @@ -261,7 +261,7 @@ describe('PTC mode typed values: keyless real-worker contracts', () => { const result = await pending expect(result.isError).toBe(true) expect(Date.now() - startedAt).toBeLessThan(5_000) - expect(ctx.jobs.list()).toEqual([]) + expect(ctx.jobs.visibleTo().list()).toEqual([]) }, 15_000) it('uses versioned Cordis DTO ids directly for running and pending Plugins, then confirms removal', async () => { diff --git a/packages/subagent/subagent/src/run-settlement.ts b/packages/subagent/subagent/src/run-settlement.ts index 02f61a7baf..34baacbcd4 100644 --- a/packages/subagent/subagent/src/run-settlement.ts +++ b/packages/subagent/subagent/src/run-settlement.ts @@ -37,7 +37,7 @@ function failureDetail(result: SubagentResult): string { function runOutcome(result: SubagentResult): JobOutcome { switch (result.stopReason) { case 'completed': - return { status: 'completed', output: finalText(result.output) } + return { status: 'completed', result: finalText(result.output) } case 'aborted': return result.diagnostic === undefined ? { status: 'killed' } diff --git a/packages/subagent/subagent/tests/run-settlement.spec.ts b/packages/subagent/subagent/tests/run-settlement.spec.ts index 146181d43a..20fc514504 100644 --- a/packages/subagent/subagent/tests/run-settlement.spec.ts +++ b/packages/subagent/subagent/tests/run-settlement.spec.ts @@ -9,7 +9,7 @@ const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096 describe('outcome mapping helpers', () => { it.each([ - ['completed', { status: 'completed', output: 'partial' }], + ['completed', { status: 'completed', result: 'partial' }], ['aborted', { status: 'killed' }], ['error', { status: 'failed', detail: 'error' }], ['max-tokens', { status: 'failed', detail: 'max-tokens' }], @@ -34,7 +34,7 @@ describe('outcome mapping helpers', () => { dispose() { order.push('dispose'); return Promise.resolve() }, }) order.push('reported') - expect(completed).toEqual({ status: 'completed', output: 'ok' }) + expect(completed).toEqual({ status: 'completed', result: 'ok' }) expect(order).toEqual(['dispose', 'reported']) // An infrastructure rejection still disposes and reports failed. diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 03b56a3c9d..2c87bbf213 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -537,7 +537,7 @@ export function apply(ctx: Context, config: Config): void { const id = jobs.start({ kind: 'subagent', label: args.description, - owner: parent, + owner: parent.id, run: () => { const controller = new AbortController() const start = runtimeCtx.subagents.start(config.provider, { ...request, signal: controller.signal }) @@ -546,7 +546,7 @@ export function apply(ctx: Context, config: Config): void { controller.abort(reason ?? 'background subagent task killed') }, done: settleStart(start, controller.signal), - // No readOutput: the child session owns intermediate detail. + // No output sources: the child session owns intermediate detail. } }, }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index e5f9502a43..c1b2046182 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -883,7 +883,7 @@ describe('dsh-tool-subagent background mode', () => { }) expect(text(collected)).toBe('background answer\n[status: completed]') - // Final-output reads are idempotent (not consumed). + // The result rides the first read after settlement; a later read carries only the status. const again = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('collect-2'), @@ -891,7 +891,7 @@ describe('dsh-tool-subagent background mode', () => { arguments: { job_id: 'subagent-1' }, agent: parent, }) - expect(text(again)).toBe('background answer\n[status: completed]') + expect(text(again)).toBe('(no new output)\n[status: completed]') }) it('preserves provider diagnostics in one-shot background failure detail', async () => { @@ -972,7 +972,7 @@ describe('dsh-tool-subagent background mode', () => { const result = await resultPromise expect(result.isError).toBe(true) - expect(ctx.jobs.list(parent)).toEqual([]) + expect(ctx.jobs.visibleTo(parent.id).list()).toEqual([]) }) it('rejects startup when the provider changes during asynchronous route preflight', async () => { @@ -1082,7 +1082,8 @@ describe('dsh-tool-subagent background mode', () => { arguments: { job_id: 'subagent-1', wait: true }, agent: parent, }) - expect(text(output)).toBe('(no new output)\n[status: killed]') + // The registry records the model's kill reason as the terminal detail. + expect(text(output)).toBe('(no new output)\n[status: killed, no longer needed]') }) it('reports startup rollback failure after cancellation as a failed job', async () => { @@ -1169,7 +1170,7 @@ describe('dsh-tool-subagent background mode', () => { // The aborted children settle as killed tasks. const killed = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('w1'), name: 'job_output', arguments: { job_id: 'subagent-1', wait: true }, agent: parent }) - expect(text(killed)).toBe('(no new output)\n[status: killed]') + expect(text(killed)).toBe('(no new output)\n[status: killed, superseded]') }) }) @@ -1240,7 +1241,7 @@ describe('dsh-tool-subagent continuable background mode', () => { expect(match).not.toBeNull() const [, childId] = match! // No Task was created for the continuable child. - expect(ctx.jobs.list(parent)).toEqual([]) + expect(ctx.jobs.visibleTo(parent.id).list()).toEqual([]) await vi.waitFor(() => { expect(ctx.agents.get(SessionId(childId!))).toBeUndefined() @@ -1271,7 +1272,7 @@ describe('dsh-tool-subagent continuable background mode', () => { if (result.isError) throw new Error('expected foreground subagent success') expect(result.value).toMatchObject({ kind: 'foreground' }) expect(text(result)).toBe('continuable answer') - expect(ctx.jobs.list(parent)).toEqual([]) + expect(ctx.jobs.visibleTo(parent.id).list()).toEqual([]) }) it('isolates a cancelled continuable preparation from a concurrent sibling', async () => { diff --git a/packages/terminal/tool-terminal/src/background.ts b/packages/terminal/tool-terminal/src/background.ts new file mode 100644 index 0000000000..47494f7cff --- /dev/null +++ b/packages/terminal/tool-terminal/src/background.ts @@ -0,0 +1,31 @@ +/** + * Generic-job adaptation for background terminal sends: the registry pull + * source over the backend's consuming send reader. + * + * @module @deepseek-ai/dsh-tool-terminal/background + */ + +import type { JobOutputSource } from '@deepseek-ai/dsh-jobs' +import type { TerminalSendOperation } from '@deepseek-ai/dsh-terminal' +import { renderSendRead } from './render.ts' + +/** + * Adapt the backend's consuming send reader to a registry pull source. The + * reader has no offsets of its own — each call hands over what arrived since + * the previous one — so the source counts the bytes it delivered to keep the + * registry's cursor monotonic. It binds lazily because the send starts inside + * the job starter, after the registry admitted the job; a read before that + * point delivers nothing. + * @param operation - the live send, once the starter has begun it. + * @returns one unlabeled source over the send's bounded delta. + */ +export function sendSource(operation: () => TerminalSendOperation | undefined): JobOutputSource { + return { + read: (fromByte) => { + const live = operation() + if (live === undefined) return { text: '', nextOffset: fromByte, lossy: false } + const text = renderSendRead(live.readOutput()) + return { text, nextOffset: fromByte + Buffer.byteLength(text), lossy: false } + }, + } +} diff --git a/packages/terminal/tool-terminal/src/index.ts b/packages/terminal/tool-terminal/src/index.ts index 880cfcd31f..417c1537c4 100644 --- a/packages/terminal/tool-terminal/src/index.ts +++ b/packages/terminal/tool-terminal/src/index.ts @@ -9,11 +9,12 @@ import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { TerminalSessionId } from '@deepseek-ai/dsh-terminal' -import type { TerminalSendResult, TerminalSessionId as TerminalSessionIdType, TerminalSignal } from '@deepseek-ai/dsh-terminal' +import type { TerminalSendOperation, TerminalSendResult, TerminalSessionId as TerminalSessionIdType, TerminalSignal } from '@deepseek-ai/dsh-terminal' import type {} from '@deepseek-ai/dsh-jobs' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' -import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts' +import { sendSource } from './background.ts' +import { boundTerminalText, renderList, renderRead, renderSend, renderSpawn } from './render.ts' declare module '@deepseek-ai/dsh-jobs' { interface JobKindMap { @@ -252,23 +253,25 @@ export function apply(ctx: Context, config: Config = {}): void { const jobs = ctx.get('jobs') if (jobs === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') let cancelRequested = false + let operation: TerminalSendOperation | undefined const jobId = jobs.start({ kind: 'pty-send', label: `${id}: ${args.text || '(input)'}`, - owner, + owner: owner.id, outputLimitBytes: maxResultBytes, + output: [sendSource(() => operation)], run: () => { - const operation = ctx.terminals.startSend(owner, id, request) + const started = ctx.terminals.startSend(owner, id, request) + operation = started return { cancel: () => { cancelRequested = true - operation.cancel() + started.cancel() }, - done: operation.done.then( + done: started.done.then( result => ({ status: cancelRequested ? 'killed' as const : 'completed' as const, detail: sendDetail(result) }), (error: unknown) => ({ status: 'failed' as const, detail: String(error) }), ), - readOutput: () => renderSendRead(operation.readOutput()), } }, }) diff --git a/packages/terminal/tool-terminal/tests/tools.spec.ts b/packages/terminal/tool-terminal/tests/tools.spec.ts index 15151f7bd2..eb80741bf8 100644 --- a/packages/terminal/tool-terminal/tests/tools.spec.ts +++ b/packages/terminal/tool-terminal/tests/tools.spec.ts @@ -8,10 +8,11 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { renderToolsSdk } from '@deepseek-ai/dsh-tools' import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' import TerminalSessionService, { TerminalSessionId } from '@deepseek-ai/dsh-terminal' -import type { TerminalBackend, TerminalBackendSession, TerminalSendOperation, TerminalSendRequest, TerminalSessionStatus, TerminalSignal } from '@deepseek-ai/dsh-terminal' +import type { TerminalBackend, TerminalBackendSession, TerminalSendOperation, TerminalSendRead, TerminalSendRequest, TerminalSessionStatus, TerminalSignal } from '@deepseek-ai/dsh-terminal' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import * as ToolPty from '@deepseek-ai/dsh-tool-terminal' +import { sendSource } from '../src/background.ts' function fakeAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) @@ -54,7 +55,11 @@ class StubSession implements TerminalBackendSession { })) const operation: TerminalSendOperation = { done, - readOutput: () => ({ delta: this.delta, truncated: this.deltaTruncated }), + readOutput: () => { + const delta = this.delta + this.delta = '' + return { delta, truncated: this.deltaTruncated } + }, cancel: () => { if (cancelled) return false cancelled = true @@ -402,6 +407,19 @@ describe('tool-terminal foreground API', () => { }) }) +describe('sendSource', () => { + it('reads nothing before the send starts and counts delivered bytes afterwards', () => { + const reads: TerminalSendRead[] = [{ delta: '界', truncated: false }, { delta: '', truncated: true }] + const send: { operation?: TerminalSendOperation } = {} + const source = sendSource(() => send.operation) + expect(source.read(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) + send.operation = { readOutput: () => reads.shift()!, cancel: () => false, done: new Promise(() => {}) } + expect(source.read(0)).toEqual({ text: '界', nextOffset: 3, lossy: false }) + // A truncated delta carries the same marker the foreground read shows. + expect(source.read(3)).toEqual({ text: '[output truncated]', nextOffset: 21, lossy: false }) + }) +}) + describe('tool-terminal task integration', () => { it('registers a generic task and exposes incremental output', async () => { const { ctx, agent } = await setup(true) From 2132b7beebc141a371afb2cedcac7020f8393108 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 16:43:15 +0800 Subject: [PATCH 034/141] refactor(session-controller): drop the job roster from the control stream The control baseline and frames carry queues and projections only; the SessionJob row type, the jobs frame, the registry subscription, and the client jobsBySession mirror are gone, and every client fixture drops the empty mirror. The roster moves to dsh-api-job-controller's job.rows stream in the next change. --- packages/api/remotes/src/client/index.ts | 1 - .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 3 +- packages/api/session-controller/README.zh.md | 3 +- packages/api/session-controller/package.json | 5 - .../src/client/sessions/manager.ts | 22 -- .../src/client/sessions/service.ts | 13 +- .../api/session-controller/src/control.ts | 48 +--- packages/api/session-controller/src/types.ts | 21 -- .../tests/control-jobs.host.spec.ts | 248 ------------------ .../tests/manager.client.spec.ts | 65 +---- .../tests/transport.client.spec.ts | 4 +- .../locale/tests/language-row.client.spec.tsx | 2 +- .../ui-chat/tests/chat-view.client.spec.tsx | 2 +- .../tests/gate-branch-tails.client.spec.tsx | 4 +- .../tests/transcript-view-row.client.spec.tsx | 2 +- .../conversation-registry.client.spec.ts | 2 +- .../tests/enter-behavior-row.client.spec.tsx | 2 +- .../tests/input-bar.client.spec.tsx | 2 +- .../tests/input-matrix.client.spec.tsx | 2 +- .../tests/input-scenarios.client.spec.tsx | 2 +- .../tests/skeleton.client.spec.tsx | 2 +- .../tests/ui-session.client.spec.ts | 2 +- .../tests/conversation-ui.client.spec.tsx | 2 +- .../tests/appearance-row.client.spec.tsx | 2 +- .../tests/font-size-row.client.spec.tsx | 2 +- .../tests/coverage-tails.client.spec.tsx | 2 +- .../ui-tool/tests/diff-card.client.spec.tsx | 6 +- .../ui-tool/tests/image-card.client.spec.tsx | 2 +- .../ui-tool/tests/read-card.client.spec.tsx | 6 +- .../ui-tool/tests/search-card.client.spec.tsx | 2 +- .../tests/terminal-card.client.spec.tsx | 8 +- .../ui-tool/tests/web-card.client.spec.tsx | 2 +- .../ui-trajectory/tests/views.client.spec.tsx | 2 +- .../tests/plan-review-panel.client.spec.tsx | 2 +- .../user-questions-composer.client.spec.tsx | 2 +- .../tests/workflow-run.client.spec.tsx | 2 +- .../ui-workspace/tests/apply.client.spec.ts | 2 +- .../ui-workspace/tests/tree.client.spec.ts | 2 +- .../tests/workspace-browser.client.spec.tsx | 2 +- .../tests/workspace-picker.client.spec.tsx | 2 +- .../tests/workspaces-service.client.spec.ts | 2 +- .../client-runtime/src/sessions.ts | 2 +- pnpm-lock.yaml | 3 - 44 files changed, 52 insertions(+), 466 deletions(-) delete mode 100644 packages/api/session-controller/tests/control-jobs.host.spec.ts diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index ce08f3dbe3..e0185c5176 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -34,7 +34,6 @@ export type {} from '@deepseek-ai/dsh-api-job-controller/remote' export type * from '@deepseek-ai/dsh-api-job-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' -export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 9f55558821..ad6ea9d524 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 4414cd7221b40cc880122415e83b6dc3818df481 -README.zh.md: 8f457d4a94f57c802f67624d9c84ba15c78b1ada +README.md: 941c255d3923b4b51635381b2f45f83780bb60c8 +README.zh.md: 3dfe2a206fa7657e57e9c3b43e40fcd75e8c1bc0 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 4414cd7221..941c255d39 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. -A `SessionJob` row carries `record: true` exactly when the job declared an observation record; streaming that record is the job controller's `job.observe` (see [`dsh-api-job-controller`](../job-controller/README.md)), while this package keeps the roster on the control stream. +Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries queues and projections only. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. @@ -61,7 +61,6 @@ No direct effect; model requests remain owned by the Agent and LLM packages. -- Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. - File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 8f457d4a94..3dfe2a206f 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 -`SessionJob` 行恰在该 job 声明了观测 record 时携带 `record: true`;流式发送该 record 的是 job 控制器的 `job.observe`(见 [`dsh-api-job-controller`](../job-controller/README.zh.md)),本包只在控制流上保留名册。 +后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载队列与投影。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 @@ -61,7 +61,6 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 -- Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 - follow 恢复失败会对调用方可见,而不会无限重试。 - 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index e60bfd4517..e836e921ae 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -82,7 +82,6 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", @@ -101,9 +100,6 @@ "@deepseek-ai/dsh-workspace": "workspace:^" }, "peerDependenciesMeta": { - "@deepseek-ai/dsh-jobs": { - "optional": true - }, "@deepseek-ai/dsh-session-persistence": { "optional": true }, @@ -121,7 +117,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index fd39fefedf..0c143d7b6d 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -10,7 +10,6 @@ import type { SessionControlFrame, SessionQueuedItem, SessionSummary, - SessionJob as JobView, } from '../../types.ts' import { mergeOrderedBaseline } from '../ordered-baseline.ts' import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' @@ -56,8 +55,6 @@ export interface SessionListSnapshot { phase: SessionListPhase error: RemoteFailure | null subagentsByParent: Readonly> - /** Background jobs per session; an absent key is an empty set. */ - jobsBySession: Readonly> currentAddress: SubagentAddress | undefined } @@ -126,12 +123,6 @@ export class SessionManager { private readonly catalogStale = new Set() private readonly openCatalogs = new Set() private readonly catalogDebounce = new Map>() - /** - * Background jobs per session, last-wins from Session Controller's control - * stream. An empty set is stored as an absent key, so absence and `[]` are - * one representation. - */ - private readonly jobsBySession = new Map() private selected: SessionId | undefined @@ -669,12 +660,6 @@ export class SessionManager { this.notifier.markDirty() return } - if (frame.type === 'jobs') { - if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId) - else this.jobsBySession.set(frame.sessionId, frame.jobs) - this.notifier.markDirty() - return - } this.queues.set(frame.sessionId, frame.items) this.sessions.get(frame.sessionId)?.handleControlFrame(frame) } @@ -685,11 +670,6 @@ export class SessionManager { this.queues.set(sessionId as SessionId, items) } - this.jobsBySession.clear() - for (const [sessionId, jobs] of Object.entries(baseline.jobs)) { - if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs) - } - for (const [sessionId, block] of Object.entries(baseline.projections)) { const store = this.projectionStore(sessionId as SessionId) const asOfSeq = sessionSeqCursor(block.asOfSeq) @@ -739,7 +719,6 @@ export class SessionManager { if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false) else this.sessions.get(sessionId)?.handleRemoved() this.queues.delete(sessionId) - this.jobsBySession.delete(sessionId) if (!durableSubagent) this.projectionStores.delete(sessionId) const inflightCatalog = this.catalogInflight.get(sessionId) if (inflightCatalog !== undefined) { @@ -951,7 +930,6 @@ export class SessionManager { phase: this.listPhase, error: this.listError, subagentsByParent: Object.fromEntries(this.catalogs), - jobsBySession: Object.fromEntries(this.jobsBySession), currentAddress: current === undefined ? undefined : this.addresses.get(current), } } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index d1d840c513..521752da39 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -20,7 +20,6 @@ import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts' -import type { SessionJob as JobView } from '../../types.ts' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import { createSnapshotStore, type SnapshotStore, @@ -76,12 +75,6 @@ export interface SessionListState { phase: SessionListPhase /** Direct durable catalogs keyed by their selected parent address. */ subagentsByParent: Readonly> - /** - * Background jobs each session can see, mirrored last-wins from Session - * Controller's control baseline and `jobs` frames. A missing key is an empty - * set, so consumers read absence rather than a sentinel. - */ - jobsBySession: Readonly> /** Current session's catalog-derived address, absent on ordinary navigation. */ currentAddress: SubagentAddress | undefined } @@ -233,7 +226,7 @@ export class ClientSessions implements ISessions { ) this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'pending', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, }) // The manager owns wire truth; the store is its projection. Manager // notifications are already microtask-batched. @@ -576,7 +569,7 @@ export class ClientSessions implements ISessions { /** Project the manager's list snapshot into the store (title derivation is display-only). */ private projectList(): void { const { - items, current, phase, subagentsByParent, jobsBySession, currentAddress, + items, current, phase, subagentsByParent, currentAddress, } = this.manager.getListSnapshot() const ids: SessionId[] = [] const byId: Record = {} @@ -642,7 +635,7 @@ export class ClientSessions implements ISessions { ...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }), }) } - this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress }) + this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress }) this.pruneScopes() } diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index b96cbb7f9d..ad64f36ec0 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -1,9 +1,8 @@ -/** Live Session queue, jobs, and projection state with reconnect baselines. */ +/** Live Session queue and projection state with reconnect baselines. */ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { Deque } from '@deepseek-ai/dsh-deque' -import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' import type { Session, SessionEvent, SessionEventMap, SessionId, UserMessage, } from '@deepseek-ai/dsh-session' @@ -11,7 +10,6 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { SessionControlBaseline, SessionControlFrame, - SessionJob, SessionProjectionBaseline, SessionProjectionValues, SessionQueuedItem, @@ -21,7 +19,7 @@ import type { export class SessionControlController { private readonly streams = new Set() - /** @param ctx - Host context carrying live Agent, projection, and jobs services. */ + /** @param ctx - Host context carrying live Agent and projection services. */ constructor(private readonly ctx: Context) { ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) }) ctx.sessionProjections.onChanged((session, key, value, seq) => { @@ -33,13 +31,6 @@ export class SessionControlController { seq, }) }) - ctx.inject(['jobs'], (jobsCtx) => { - jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) }) - }) - ctx.on('session/created', (session) => { - const jobs = this.jobsFor(this.ctx.agents.get(session.id)) - if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs }) - }) ctx.effect(() => () => { for (const stream of this.streams) stream.end() this.streams.clear() @@ -67,15 +58,12 @@ export class SessionControlController { private baseline(): SessionControlBaseline { const sessions = this.ctx.sessions.list() const queues = Object.create(null) as Record - const jobs = Object.create(null) as Record for (const session of sessions) { const agent = this.ctx.agents.get(session.id) queues[session.id] = agent?.session === session ? queueItems(agent) : [] - jobs[session.id] = this.jobsFor(agent) } return { queues, - jobs, projections: this.projectionBaseline(sessions), } } @@ -106,25 +94,6 @@ export class SessionControlController { }) } - private onJobsChanged(owner: Agent | undefined): void { - if (owner !== undefined) { - this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) }) - return - } - for (const session of this.ctx.sessions.list()) { - this.broadcast({ - type: 'jobs', - sessionId: session.id, - jobs: this.jobsFor(this.ctx.agents.get(session.id)), - }) - } - } - - private jobsFor(agent: Agent | undefined): SessionJob[] { - const jobs = this.ctx.get('jobs') - return jobs === undefined ? [] : jobs.list(agent).map(jobView) - } - private broadcast(frame: SessionControlFrame): void { for (const stream of this.streams) stream.push(frame) } @@ -202,16 +171,3 @@ function promptRpcId(message: UserMessage): Pick { const source = message.source return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {} } - -function jobView(job: JobSnapshot): SessionJob { - return { - id: job.id, - kind: job.kind, - label: job.label, - status: job.status, - ...(job.detail === undefined ? {} : { detail: job.detail }), - startedAt: job.startedAt, - ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }), - ...(job.outputTotal === undefined ? {} : { record: true as const }), - } -} diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index d3ab795ecb..d65d977e9a 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -9,7 +9,6 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' @@ -479,28 +478,9 @@ export interface SessionQueuedItem { } } -/** Browser-safe background-job row. */ -export interface SessionJob { - readonly id: JobId - readonly kind: string - readonly label: string - readonly status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' - readonly detail?: string - readonly startedAt: number - readonly finishedAt?: number - /** - * Present exactly when the job declared an observation record: the row is - * observable through the job controller's `job.observe` stream, whose - * `opened` anchor carries the live byte offsets. The roster refreshes on - * lifecycle commits only, so it mirrors no byte count. - */ - readonly record?: true -} - /** Complete live control baseline emitted once per control stream generation. */ export interface SessionControlBaseline { readonly queues: Readonly> - readonly jobs: Readonly> readonly projections: Readonly> } @@ -516,7 +496,6 @@ export interface SessionProjectionUpdate { export type SessionControlFrame = | { readonly type: 'baseline'; readonly value: SessionControlBaseline } | { readonly type: 'queue'; readonly sessionId: SessionId; readonly items: readonly SessionQueuedItem[] } - | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] } | ({ readonly type: 'projection' } & SessionProjectionUpdate) diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts deleted file mode 100644 index 9d80501966..0000000000 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { JobOutcome } from '@deepseek-ai/dsh-jobs' -import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { describe, expect, it } from 'vitest' -import { SessionControlController } from '../src/control.ts' -import type { SessionControlFrame } from '../src/types.ts' - -type BaselineFrame = Extract -type JobFrame = Extract - -function producer(label = 'sleep 60') { - let settle!: (outcome: JobOutcome) => void - const reads = { count: 0 } - const spec = { - kind: 'bash' as const, - label, - run: () => ({ - cancel: () => {}, - done: new Promise((resolve) => { settle = resolve }), - readOutput: () => { reads.count += 1; return 'stolen output' }, - }), - } - return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } } -} - -async function harness(withJobs: boolean): Promise<{ - ctx: Context - session: Session - agent: Agent - control: SessionControlController -}> { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) - if (withJobs) { - await ctx.plugin(LocalJobRegistry) - ctx.jobs.attachController('session-controller-test') - } - const session = ctx.sessions.create() - const agent = { - id: session.id, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle', - ctx, - } as Agent - ctx.agents.register(agent) - const control = new SessionControlController(ctx) - await new Promise(resolve => setTimeout(resolve, 0)) - return { ctx, session, agent, control } -} - -async function baseline(control: SessionControlController): Promise { - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - const first = await iterator.next() - abort.abort() - await iterator.next() - if (first.done || first.value.type !== 'baseline') throw new Error('missing control baseline') - return first.value -} - -async function collectJobs( - iterable: AsyncIterable, - count: number, - abort: AbortController, -): Promise { - const jobs: JobFrame[] = [] - for await (const frame of iterable) { - if (frame.type !== 'jobs') continue - jobs.push(frame) - if (jobs.length >= count) abort.abort() - } - return jobs -} - -describe('Session control jobs baseline', () => { - it('represents an attached session with no jobs as an empty set', async () => { - const { session, control } = await harness(true) - const frame = await baseline(control) - expect(frame.value.jobs[session.id]).toEqual([]) - }) - - it('carries the visible set when the stream opens', async () => { - const { ctx, session, agent, control } = await harness(true) - ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent }) - const frame = await baseline(control) - const jobs = frame.value.jobs[session.id] - expect(jobs).toHaveLength(1) - const [job] = jobs ?? [] - expect(job?.startedAt).toBeTypeOf('number') - expect({ ...job, startedAt: 0 }).toEqual({ - id: 'bash-1', - kind: 'bash', - label: 'pnpm run build', - status: 'running', - startedAt: 0, - }) - }) -}) - -describe('Session control jobs updates', () => { - it('publishes existing unowned jobs when a Session attaches after the stream opens', async () => { - const { ctx, control } = await harness(true) - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } }) - const task = producer('already running') - const id = ctx.jobs.start(task.spec) - await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'jobs' } }) - - const created = ctx.sessions.create(SessionId('late-session')) - await expect(iterator.next()).resolves.toMatchObject({ - value: { - type: 'jobs', - sessionId: created.id, - jobs: [expect.objectContaining({ id, label: 'already running' })], - }, - }) - - task.settle({ status: 'completed' }) - abort.abort() - await iterator.return?.() - }) - - it('pushes the owner whole set on registration, stopping, and settlement', async () => { - const { ctx, session, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 3, abort) - - const task = producer() - const id = ctx.jobs.start({ ...task.spec, owner: agent }) - ctx.jobs.kill(id, agent, 'test') - task.settle({ status: 'killed', detail: 'signal: SIGTERM' }) - - const frames = await collected - expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id]) - expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed']) - expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM') - expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number') - }) - - it('drops internal registry fields from the browser view', async () => { - const { ctx, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 1, abort) - ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 }) - - const [frame] = await collected - expect(Object.keys(frame?.jobs[0] ?? {}).sort()).toEqual([ - 'id', - 'kind', - 'label', - 'startedAt', - 'status', - ]) - }) - - it('flags record-declaring jobs instead of mirroring a byte count the roster never refreshes', async () => { - const { ctx, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 1, abort) - ctx.jobs.start({ - kind: 'bash', - label: 'observable run', - owner: agent, - record: true, - run: (job) => { - job.append('hi') - return { cancel: () => {}, done: new Promise(() => {}) } - }, - }) - - const [frame] = await collected - expect(frame?.jobs[0]).toMatchObject({ record: true }) - expect(frame?.jobs[0]).not.toHaveProperty('outputTotal') - }) - - it('fans an unowned change out to every attached session', async () => { - const { ctx, control } = await harness(true) - const second = ctx.sessions.create() - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 2, abort) - - ctx.jobs.start(producer('open to every caller').spec) - - const frames = await collected - expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2) - expect(frames.some(frame => frame.sessionId === second.id)).toBe(true) - for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller') - }) - - it('does not resume persisted sessions while projecting an unowned change', async () => { - const { ctx, control } = await harness(true) - const coldId = SessionId('session-cold-tasks') - let loaded = false - ctx.provide('sessionPersistence', { - list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], - locate: () => undefined, - load: () => { loaded = true; throw new Error('job projection must not load a cold log') }, - } as never) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 1, abort) - - ctx.jobs.start(producer().spec) - await collected - expect(loaded).toBe(false) - expect(ctx.agents.get(coldId)).toBeUndefined() - }) - - it('reports empty sets when no jobs registry is composed', async () => { - const { session, control } = await harness(false) - const frame = await baseline(control) - expect(frame.value.jobs[session.id]).toEqual([]) - }) - - it('never consumes model output while projecting a lifecycle', async () => { - const { ctx, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 3, abort) - - const task = producer() - const id = ctx.jobs.start({ ...task.spec, owner: agent }) - ctx.jobs.kill(id, agent, 'test') - task.settle({ status: 'killed', detail: 'signal: SIGTERM' }) - await collected - - expect(task.reads.count).toBe(0) - }) - - it('never consumes model output while producing a baseline', async () => { - const { ctx, agent, control } = await harness(true) - const task = producer() - ctx.jobs.start({ ...task.spec, owner: agent }) - - const frame = await baseline(control) - - expect(frame.value.jobs[agent.id]).toHaveLength(1) - expect(task.reads.count).toBe(0) - }) - -}) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 14f7c25fca..f59d1a782d 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -187,7 +187,7 @@ describe('list lifecycle', () => { frame({ type: 'baseline', value: { - queues: {}, jobs: {}, + queues: {}, projections: { [S1]: { asOfSeq: 2, values: {} } }, }, }) @@ -200,7 +200,7 @@ describe('list lifecycle', () => { frame({ type: 'baseline', value: { - queues: {}, jobs: {}, + queues: {}, projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } }, }, }) @@ -919,64 +919,3 @@ describe('completed reminder', () => { expect(entry(manager, S2)?.completed).toBe(true) }) }) - -describe('background-job mirror', () => { - const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({ - id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over, - }) - const tasksFrame = ( - sessionId: SessionId, - jobs: unknown[], - ): Extract => ({ - type: 'jobs', sessionId, jobs: jobs as never, - }) - - it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => { - const manager = makeManager() - manager.handleControlFrame(tasksFrame(S1, [view()])) - manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) - const first = manager.getListSnapshot().jobsBySession - expect(first[S1]).toEqual([view()]) - expect(first[S2]?.[0]?.label).toBe('other') - - // Last-wins: the newer whole set replaces, it does not merge. - manager.handleControlFrame(tasksFrame(S1, [view({ status: 'completed' })])) - expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })]) - }) - - it('stores an emptied set as an absent key so absence and [] read alike', () => { - const manager = makeManager() - manager.handleControlFrame(tasksFrame(S1, [view()])) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true) - manager.handleControlFrame(tasksFrame(S1, [])) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - }) - - it('clears the mirror when the next control baseline has no jobs', () => { - const manager = makeManager() - manager.handleControlFrame(tasksFrame(S1, [view()])) - manager.handleControlFrame({ - type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, - }) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - }) - - it('drops the rows when the session is removed, whichever stream lands first', () => { - const manager = makeManager() - manager.handleSessionAdded(summary(S1, { blank: true })) - manager.handleControlFrame(tasksFrame(S1, [view()])) - manager.handleSessionRemoved(S1) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - }) - - it('notifies list subscribers so an open header re-renders without a poll', async () => { - const manager = makeManager() - const seen = vi.fn() - manager.subscribe(seen) - manager.handleControlFrame(tasksFrame(S1, [view()])) - // The notifier batches on a microtask; the frame itself is already applied. - await Promise.resolve() - expect(seen).toHaveBeenCalled() - }) -}) diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index c5f50ad7de..560874d8d0 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -327,7 +327,7 @@ describe('Session Client stream adapters', () => { it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => { const baseline: SessionControlFrame = { type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { queues: {}, projections: {} }, } const update: SessionControlFrame = { type: 'queue', sessionId: 'session-1' as never, items: [], @@ -362,7 +362,7 @@ describe('Session Client stream adapters', () => { const baseline: SessionControlFrame = { type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { queues: {}, projections: {} }, } const carrierFailed = vi.fn() const failed = vi.fn() diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index 44ddb31e7b..198127a703 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -15,7 +15,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 9e0818f37c..f1029a50b8 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -194,7 +194,7 @@ const compaction = (over: Partial = {}): CompactionSummar /** Empty sessions-list hook for the global standard-kit seat. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index 57afd2abd8..7d42abb682 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -133,7 +133,7 @@ describe('render branch tails', () => { const chat = createChatStore().create() chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget) const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) const workspaces = emptyWorkspaces() const view = render( { const chat = createChatStore().create() chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget) const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) const workspaces = emptyWorkspaces() const owners: DetailsToolOwnerProps[] = [] const view = render( diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index f946a28f1a..a22c273552 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -13,7 +13,7 @@ afterEach(cleanup) function emptySessions() { return bindSnapshotSelector(createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, })) } diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 9805bf189f..dc8f342c90 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -73,7 +73,7 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind current: undefined, phase: 'ready', subagentsByParent: {}, - jobsBySession: {}, + currentAddress: undefined, }) const sessions = { diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 44022cb553..ff8649695a 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -18,7 +18,7 @@ afterEach(() => { function emptySessions() { return bindSnapshotSelector(createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, })) } diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 0edc8b9ef6..72e41bb829 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -162,7 +162,7 @@ function bench(over?: BenchOptions) { useSessionPendingInteraction: bindSnapshotSelector(createSnapshotStore(new Map())), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index bdb878672b..a5367c6304 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -49,7 +49,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, })), useSessionPendingInteraction: bindSnapshotSelector( createSnapshotStore(new Map()), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index ec7f586683..0babbf8dc1 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -138,7 +138,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo useSession: bindSnapshotSelector(sessionStore), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, })), useSessionPendingInteraction: bindSnapshotSelector( createSnapshotStore(new Map()), diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index b431a51ff8..2e9dacdc12 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -146,7 +146,7 @@ function mount( ...listed && { [SID]: childRow }, }, current: SID, - phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore(workspaceState(workspaceRows)) const session = createSnapshotStore(snapshot) diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 3eca1b7a47..f44320b36d 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -40,7 +40,7 @@ function createSessionsBench(_ctx: Context): SessionsBench { current: undefined, phase: 'ready', subagentsByParent: {}, - jobsBySession: {}, + currentAddress: undefined, }) const bindings = new Map() diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 6839df928c..13ef3391fc 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -63,7 +63,7 @@ function props( }, current: PARENT, phase: 'ready', subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested }, - jobsBySession: {}, + currentAddress: undefined, } satisfies SessionListState function useSessions(select: (snapshot: SessionListState) => T): T { diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index c42d6aa3b6..6d1f40366b 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -21,7 +21,7 @@ const COPY: Record = { function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index 314576d572..eb33802368 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -23,7 +23,7 @@ const COPY: Record = { /** Empty global standard-kit hooks (the row reads neither). */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 04e464d968..062a4222bc 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -29,7 +29,7 @@ function listStore() { }, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) } diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index ed386b8321..d2906e6ccf 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -212,7 +212,7 @@ describe('FileMutationRow diff card', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) @@ -363,13 +363,13 @@ describe('DetailsPanel diff Output section', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) const session = createSnapshotStore(sessionSnapshot(SID)) diff --git a/packages/client/ui-tool/tests/image-card.client.spec.tsx b/packages/client/ui-tool/tests/image-card.client.spec.tsx index 53578e25d9..c5ccf03c9e 100644 --- a/packages/client/ui-tool/tests/image-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/image-card.client.spec.tsx @@ -269,7 +269,7 @@ describe('ReadImageRow keyed toolview', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, } as unknown as SessionListState) diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 092a7ce506..9e3f547b3f 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -194,7 +194,7 @@ describe('ReadRow keyed toolview', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) @@ -283,13 +283,13 @@ describe('DetailsPanel Output section (read)', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) const session = createSnapshotStore(sessionSnapshot(SID)) diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index bc5213d02e..6e62152851 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -375,7 +375,7 @@ describe('DetailsPanel Output section (search)', () => { if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, }) const session = createSnapshotStore(sessionSnapshot(SID)) const conversation = createSnapshotStore(conversationSnapshot()) diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 064c25d4ec..7e036fce66 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -390,7 +390,7 @@ describe('BashRow terminal card', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) @@ -499,13 +499,13 @@ describe('DetailsPanel Output section', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, }) const session = createSnapshotStore(sessionSnapshot(SID)) @@ -705,7 +705,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, }))} useSessionPendingInteraction={bindSnapshotSelector(attention)} useWorkspaces={bindSnapshotSelector(workspaces)} diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index f510ae050f..70a7ea5f8b 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -222,7 +222,7 @@ describe('DetailsPanel web Output section', () => { if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, }) const session = createSnapshotStore(sessionSnapshot(SID)) const conversation = createSnapshotStore(conversationSnapshot()) diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index fba1a7b77e..0626f7dc0e 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -167,7 +167,7 @@ function standaloneDuration(): Pick< /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 35575aeb4d..4bb2181469 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -48,7 +48,7 @@ const sessionList = { current: SID, phase: 'ready' as const, subagentsByParent: {}, - jobsBySession: {}, + currentAddress: undefined, } const attentionState: AttentionState = new Map() diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index e44f492b10..97cd206508 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -47,7 +47,7 @@ const sessionList = { current: SID, phase: 'ready' as const, subagentsByParent: {}, - jobsBySession: {}, + currentAddress: undefined, } const attentionState: AttentionState = new Map() diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 772c90a571..4e01c5c61c 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -299,7 +299,7 @@ const listState = (overrides: Partial = {}): SessionListState current: PARENT_ID, phase: 'ready', subagentsByParent: {}, - jobsBySession: {}, + currentAddress: undefined, ...overrides, }) diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index be05aae9df..660396aace 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -47,7 +47,7 @@ async function bench() { list: { getSnapshot: () => ({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, }), subscribe, }, diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index 1451b1450d..1f7dcf2a47 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -20,7 +20,7 @@ const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), byId: Object.fromEntries(items.map(item => [item.id, item])), current: undefined, - phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, currentAddress: undefined, }) const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({ workspaceId: wid(id), path: `/projects/${id}`, title, diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 8ee31d37c9..0349223796 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -32,7 +32,7 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial [item.id, item])), current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, + subagentsByParent: {}, currentAddress: undefined, ...overrides, }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index 1c76b8b25f..746c6b3259 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -30,7 +30,7 @@ function hook(snapshot: T) { return function select(selector: (state: T) => S): S { return selector(snapshot) } } const sessions: SessionListState = { - ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, } const noPendingInteraction: SessionPendingInteractionSnapshot = new Map() const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({ diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 393619bbb7..7d746b7fa1 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -56,7 +56,7 @@ function sessionState( current, phase, subagentsByParent: {}, - jobsBySession: {}, + currentAddress: undefined, } } diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index 419e155af7..32db7c5eac 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -216,7 +216,7 @@ export class TestSessions implements ISessions { constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) { this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + subagentsByParent: {}, currentAddress: undefined, }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b01a46c7f0..51447df391 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -836,9 +836,6 @@ importers: '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm From b7ce934b29c4aa9e5d5ac8c9e1b2f176008374b2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 17:00:49 +0800 Subject: [PATCH 035/141] refactor(job-controller): own the roster stream and the browser jobs service - job.rows({ sessionId }) streams the jobs one session can see as whole-set frames: once on open, then after each coalesced burst of lifecycle commits; output appends never refresh it - job.observe reads through visibleTo(sessionId) and carries JobView in its opened and status frames; the wire chunk is the registry's JobChunk - the client installs ctx.jobs (ClientJobs over ClientJobsModel): watchRows and observe are reference-counted streams, rows drop with the last watcher or a terminal failure, observations keep their render tail - the controller no longer needs the agent registry --- packages/api/job-controller/README.i18n.yaml | 4 +- packages/api/job-controller/README.md | 18 +- packages/api/job-controller/README.zh.md | 18 +- .../api/job-controller/src/client/index.ts | 36 +- .../api/job-controller/src/client/model.ts | 86 +++-- .../api/job-controller/src/client/service.ts | 172 ++++++--- packages/api/job-controller/src/index.ts | 47 ++- packages/api/job-controller/src/observe.ts | 128 ++----- packages/api/job-controller/src/rows.ts | 54 +++ packages/api/job-controller/src/types.ts | 63 ++-- packages/api/job-controller/src/wake.ts | 60 +++ .../tests/client-apply.client.spec.ts | 28 +- .../api/job-controller/tests/host-harness.ts | 92 +++++ .../job-controller/tests/model.client.spec.ts | 344 ++++++++++++------ .../job-controller/tests/observe.host.spec.ts | 224 +++++------- .../job-controller/tests/rows.host.spec.ts | 89 +++++ .../api/job-controller/tsconfig.host.json | 5 +- 17 files changed, 937 insertions(+), 531 deletions(-) create mode 100644 packages/api/job-controller/src/rows.ts create mode 100644 packages/api/job-controller/src/wake.ts create mode 100644 packages/api/job-controller/tests/host-harness.ts create mode 100644 packages/api/job-controller/tests/rows.host.spec.ts diff --git a/packages/api/job-controller/README.i18n.yaml b/packages/api/job-controller/README.i18n.yaml index 49a565d20f..8ad9d12dc8 100644 --- a/packages/api/job-controller/README.i18n.yaml +++ b/packages/api/job-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/job-controller/README.md -README.md: 6aaab1bff1f13cc21672a039170f811b570230db -README.zh.md: 47e68802346fbc5e41e74171a42afa17289ee156 +README.md: b21327eeea33d84d9218f6869fbe9f9b3d8c6052 +README.zh.md: 32e67646d10f49e2626f0dc2a094e1eb303cc211 diff --git a/packages/api/job-controller/README.md b/packages/api/job-controller/README.md index 6aaab1bff1..b21327eeea 100644 --- a/packages/api/job-controller/README.md +++ b/packages/api/job-controller/README.md @@ -1,5 +1,5 @@ --- -description: "Host and Client job observation: stream one background job's output record to the browser without touching the model's consuming cursor." +description: "Host and Client job streams: the roster one session can see and one job's retained output, mirrored to the browser without touching the model's consuming cursor." kind: "package-reference" --- # Job Controller @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its one Remote stream, `job.observe`, delivers a record-declaring job's retained output from an absolute byte offset; the Client half installs `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views the session-header job list renders. The roster itself is not here: `SessionJob` rows still ride the session control stream owned by [`dsh-api-session-controller`](../session-controller/README.md), and a row's `record` says whether this controller has anything to stream for it. +`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its two Remote streams are projections of `ctx.jobs`: `job.rows` mirrors the jobs one session can see as whole-set frames, and `job.observe` delivers one job's retained output from an absolute byte offset. The Client half installs `ctx.jobs`, the reference-counted service whose rosters and accumulated views the session-header job list renders. Neither stream touches the model's consuming cursor or the completion-notice ledger. ## Table of Contents @@ -22,15 +22,15 @@ English | [中文](README.zh.md) ## Use this package -The Host controller requires the live Agent registry and the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without them. `job.observe({ sessionId?, jobId, from? })` resolves the fenced-read caller from the request's session — an unowned job needs no session — and yields one `opened` anchor, coalesced `output` frames, then one terminal `status` once the job has settled and its record is drained, after which the stream closes normally. Record reads are non-consuming: the model-facing `job_output` cursor and completion-notice state never observe them. A job the session does not own, a job without a record, or an unknown job rejects the stream. Reconnecting callers resume by passing the last frame's `next` as `from`; a `from` below the oldest retained byte gets a `lossy` first frame instead of an error. +The Host controller requires the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without it. `job.rows({ sessionId })` yields the session's visible set — its own jobs plus every unowned job — once on open and again after each coalesced burst of lifecycle commits (registration, progress, stopping, settlement, removal); output appends never refresh the roster, since a settled projection already carries the final byte count. `job.observe({ sessionId?, jobId, from? })` reads through `visibleTo(sessionId)` — an unowned job needs no session — and yields one `opened` anchor carrying the job projection, coalesced `output` frames, then one terminal `status` once the job has settled and its ring is drained, after which the stream closes normally. Both reads are non-consuming: the model's `job_output` cursor and notice state never observe them. -The Client entry provides `ClientJobOutput` (`ctx.jobOutput`) over `ClientJobOutputModel`. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, records the terminal status or a stream failure on the view, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because observation (re)opens run on caller stacks that have not declared `remote.job`. +The Client entry provides `ClientJobs` (`ctx.jobs`) over `ClientJobsModel`. `watchRows(sessionId)` keeps one roster stream open per watched session however many viewers hold it and drops the rows after the last release; a reconnect's first frame is already the whole truth. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, closes the view on the terminal frame or records a stream failure on it, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because stream (re)opens run on caller stacks whose dynamic context has not declared `remote.job`. ### Config | Field | Default | Meaning | |---|---:|---| -| `observeFlushMs` | `100` | Coalescing window after new record output before an observation read, in milliseconds | +| `observeFlushMs` | `100` | Coalescing window after a registry commit before the next rows or output read, in milliseconds | | `observeMaxFrameBytes` | `65,536` | Soft byte budget per observation output frame; one larger chunk ships whole | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-job-controller) is the exhaustive source for accepted fields and their JSDoc. @@ -50,9 +50,9 @@ No direct effect; observation reads never touch model requests. -- The record is a best-effort live preview, not a terminal transcript: producers copy stdout and stderr per poll round, so the two streams' writes inside one poll window reach the record stdout first, and the client concatenates chunks regardless of `channel`. -- The stream is process-local: a Host restart loses every record, and a resumed observation then anchors on an empty registry. -- Per-session fencing is enforced by the registry read; the Remote layer itself serves any connected browser, matching the session control stream's `jobsBySession` broadcast. +- The ring is a best-effort live preview, not a terminal transcript: producers' pull sources are copied per poll round, so two streams' writes inside one poll window land stdout first, and the client concatenates chunks regardless of `channel`. +- Both streams are process-local: a Host restart loses every ring and roster; a resumed observation then anchors on an empty registry, and a re-opened roster starts empty. +- Per-session fencing is enforced by the registry's `visibleTo` read; the Remote layer itself serves any connected browser. ### Dev Note @@ -64,4 +64,4 @@ None. -**Runtime invariant:** No companion is published. The controller is a stateless projection of `ctx.jobs` reads; the registry's own `@deepseek-ai/dsh-jobs/invariant` owns the offset relations this stream forwards. +**Runtime invariant:** No companion is published. The controller is a stateless projection of `ctx.jobs` reads; the registry's own `@deepseek-ai/dsh-jobs/invariant` owns the offset relations these streams forward. diff --git a/packages/api/job-controller/README.zh.md b/packages/api/job-controller/README.zh.md index 47e6880234..32e67646d1 100644 --- a/packages/api/job-controller/README.zh.md +++ b/packages/api/job-controller/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Host 与 Client 的 job 观测:把一个后台任务的输出 record 流式送到浏览器,不触碰模型的消耗型游标。" +description: "Host 与 Client 的 job 流:把一个会话看得到的名册与一个 job 的保留输出镜像到浏览器,不触碰模型的消耗型游标。" kind: "package-reference" --- # Job Controller @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它唯一的 Remote 流 `job.observe` 从绝对字节偏移发送声明了 record 的 job 的保留输出;Client 半侧安装 `ctx.jobOutput`——按 job 引用计数的观测服务,会话头部任务列表渲染其累积视图。名册不在这里:`SessionJob` 行仍随 [`dsh-api-session-controller`](../session-controller/README.zh.md) 拥有的会话控制流到达,行上的 `record` 说明本控制器是否有东西可流。 +`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它的两条 Remote 流都是 `ctx.jobs` 的投影:`job.rows` 以整集帧镜像一个会话看得到的 job;`job.observe` 从绝对字节偏移发送一个 job 的保留输出。Client 半侧安装 `ctx.jobs`——按引用计数的服务,会话头部任务列表渲染其名册与累积视图。两条流都不触碰模型的消耗型游标与完成播报台账。 ## 目录 @@ -22,15 +22,15 @@ kind: "package-reference" ## 使用本包 -Host 控制器要求活体 Agent 注册表与 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少任一则不加载。`job.observe({ sessionId?, jobId, from? })` 由请求的 session 解析围栏读取者——无主 job 不需要 session——先产出一个 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且 record 排干后产出一个终态 `status`,随后流正常关闭。record 读取是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。session 不拥有的 job、没有 record 的 job 或未知 job 会拒绝该流。重连方把上一帧的 `next` 作为 `from` 续读;低于最老保留字节的 `from` 得到带 `lossy` 的首帧而非错误。 +Host 控制器要求 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少则不加载。`job.rows({ sessionId })` 产出该会话可见的集合——自己的 job 加上所有无主 job——打开时一次,之后每一轮聚合过的生命周期提交(注册、进度、停止中、结算、移除)后再一次;输出追加从不刷新名册,因为结算后的投影已经带着最终字节数。`job.observe({ sessionId?, jobId, from? })` 经 `visibleTo(sessionId)` 读取——无主 job 不需要 session——先产出一个携带 job 投影的 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且环排干后产出一个终态 `status`,随后流正常关闭。两种读取都是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。 -Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jobOutput`)。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,把终态或流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为观测的(重)开启跑在未声明 `remote.job` 的调用栈上。 +Client 入口在 `ClientJobsModel` 之上提供 `ClientJobs`(`ctx.jobs`)。`watchRows(sessionId)` 不论多少查看器持有都只为每个被关注的会话开一条名册流,最后一个释放后丢弃行;重连后的首帧已经是完整事实。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,在终态帧上关闭视图或把流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为流的(重)开启跑在未声明 `remote.job` 的调用栈上。 ### 配置 | 字段 | 默认值 | 含义 | |---|---:|---| -| `observeFlushMs` | `100` | 新 record 输出到一次观测读取之间的聚合窗口,毫秒 | +| `observeFlushMs` | `100` | 注册表提交到下一次名册或输出读取之间的聚合窗口,毫秒 | | `observeMaxFrameBytes` | `65,536` | 每个观测输出帧的软字节预算;更大的单块整块发送 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-job-controller)是所有受支持字段及其 JSDoc 的完整来源。 @@ -50,9 +50,9 @@ Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jo -- record 是尽力而为的实时预览,不是终端转录:生产者按轮询轮次复制 stdout 与 stderr,同一轮询窗口内两条流的写入先 stdout 后到达 record,客户端拼接 chunk 时也不区分 `channel`。 -- 流是进程本地的:Host 重启丢失全部 record,续读的观测随后锚定在空注册表上。 -- 按 session 的围栏由注册表读取强制;Remote 层本身服务任何已连接浏览器,与会话控制流对 `jobsBySession` 的广播一致。 +- 环是尽力而为的实时预览,不是终端转录:生产者的拉取源按轮询轮次复制,同一轮询窗口内两条流的写入先 stdout 落地,客户端拼接 chunk 时也不区分 `channel`。 +- 两条流都是进程本地的:Host 重启丢失全部环与名册,续读的观测随后锚定在空注册表上,重开的名册从空开始。 +- 按 session 的围栏由注册表的 `visibleTo` 读取强制;Remote 层本身服务任何已连接浏览器。 ### 开发备注 @@ -64,4 +64,4 @@ Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jo -**运行时不变式:** 不发布伴生入口。控制器是 `ctx.jobs` 读取的无状态投影;本流转发的偏移关系由注册表自己的 `@deepseek-ai/dsh-jobs/invariant` 拥有。 +**运行时不变式:** 不发布伴生入口。控制器是 `ctx.jobs` 读取的无状态投影;这些流转发的偏移关系由注册表自己的 `@deepseek-ai/dsh-jobs/invariant` 拥有。 diff --git a/packages/api/job-controller/src/client/index.ts b/packages/api/job-controller/src/client/index.ts index a76d507332..61c59ce52b 100644 --- a/packages/api/job-controller/src/client/index.ts +++ b/packages/api/job-controller/src/client/index.ts @@ -1,34 +1,36 @@ /** - * Job Controller client half: installs `ctx.jobOutput` over the generated - * `job` Remote namespace. The plugin resolves both Remote faces it drives - * while its own context is current, because observation (re)opens run on - * caller stacks — a React event, a carrier retry — whose dynamic context has - * not declared `remote.job`. + * Job Controller client half: installs `ctx.jobs` over the generated `job` + * Remote namespace. The plugin resolves both Remote faces it drives while its + * own context is current, because stream (re)opens run on caller stacks — a + * React event, a carrier retry — whose dynamic context has not declared + * `remote.job`. * @module @deepseek-ai/dsh-api-job-controller/client */ import type { Context } from '@deepseek-ai/cordis' -import { ClientJobOutputModel } from './model.ts' -import { ClientJobOutput } from './service.ts' -import type { JobObserveRemote } from './service.ts' +import { ClientJobsModel } from './model.ts' +import { ClientJobs } from './service.ts' +import type { JobsRemote } from './service.ts' -export { ClientJobOutputModel } from './model.ts' -export type { JobOutputSnapshot, JobOutputSource, ObservedJob } from './model.ts' -export { ClientJobOutput } from './service.ts' -export type { IJobOutput, JobObserveRemote, JobRemote } from './service.ts' -export type { JobObserveFrame, JobObserveRequest, JobObserveStatus, JobWireChunk } from '../types.ts' +export { ClientJobsModel } from './model.ts' +export type { JobsSnapshot, JobsSource, ObservedJob } from './model.ts' +export { ClientJobs } from './service.ts' +export type { IJobs, JobRemote, JobsRemote } from './service.ts' +export type { + JobChunk, JobObserveFrame, JobObserveRequest, JobRowsFrame, JobRowsRequest, JobView, +} from '../types.ts' /** Required Client Remote services. */ export const inject = ['remote', 'remote.job'] /** - * Install the client job-output service. + * Install the client jobs service. * @param ctx - Client root Context. */ export function apply(ctx: Context): void { - const remotes = ctx.remote as unknown as JobObserveRemote - new ClientJobOutput(ctx, { + const remotes = ctx.remote as unknown as JobsRemote + new ClientJobs(ctx, { $stream: options => remotes.$stream(options), job: remotes.job, - }, new ClientJobOutputModel()) + }, new ClientJobsModel()) } diff --git a/packages/api/job-controller/src/client/model.ts b/packages/api/job-controller/src/client/model.ts index 1c44aa039a..2b58208bb0 100644 --- a/packages/api/job-controller/src/client/model.ts +++ b/packages/api/job-controller/src/client/model.ts @@ -1,13 +1,15 @@ /** - * React-free client job-record observation state: per-job accumulated output - * views fed by `job.observe` frames. Pure data plus subscriptions — transport - * wiring stays in the client service, UI stays in slot components. + * React-free client job state: the roster each watched session can see, fed + * by `job.rows` frames, and per-job accumulated output views fed by + * `job.observe` frames. Pure data plus subscriptions — transport wiring stays + * in the client service, UI stays in slot components. * @module @deepseek-ai/dsh-api-job-controller/client/model */ import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -import type { JobObserveFrame, JobObserveStatus } from '../types.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { JobObserveFrame, JobView } from '../types.ts' /** Bounded per-job render tail, in UTF-16 code units. */ const RENDER_TAIL_LIMIT = 128 * 1024 @@ -19,25 +21,28 @@ export interface ObservedJob { readonly text: string /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ readonly gapBefore: boolean - /** Latest status delivered on the observation stream. */ - readonly status: JobObserveStatus - readonly detail?: string /** True while the observation stream is open and the job has not settled. */ readonly streaming: boolean /** Terminal observation failure, when the stream ended abnormally. */ readonly error?: string } -/** Immutable client job-output state. */ -export interface JobOutputSnapshot { +/** Immutable client job state. */ +export interface JobsSnapshot { + /** + * The jobs each watched session can see, keyed by session id. A session + * nobody watches, or one that sees no job, has no key, so consumers read + * absence rather than a sentinel. + */ + readonly rows: Readonly> /** Live observation state keyed by job id. */ readonly observed: Readonly> } -/** Bare observable source for the client job-output snapshot. */ -export interface JobOutputSource { +/** Bare observable source for the client job snapshot. */ +export interface JobsSource { /** Read the identity-stable current snapshot. */ - getSnapshot(): JobOutputSnapshot + getSnapshot(): JobsSnapshot /** * Subscribe to snapshot changes. * @param listener - invalidation callback. @@ -53,18 +58,21 @@ interface ObservedState { cursor: number | undefined } -/** Owns per-job observation state. */ -export class ClientJobOutputModel implements JobOutputSource { +/** Owns the per-session rosters and per-job observation state. */ +export class ClientJobsModel implements JobsSource { + private readonly rowsBySession = new Map() private readonly observedStates = new Map() private readonly listeners = new Set<() => void>() - private snapshotCache: JobOutputSnapshot = { observed: {} } + private snapshotCache: JobsSnapshot = { rows: {}, observed: {} } private snapshotDirty = false - getSnapshot(): JobOutputSnapshot { + getSnapshot(): JobsSnapshot { if (this.snapshotDirty) { + const rows: Record = {} + for (const [id, jobs] of this.rowsBySession) rows[id] = jobs const observed: Record = {} for (const [id, state] of this.observedStates) observed[id] = state.view - this.snapshotCache = { observed } + this.snapshotCache = { rows, observed } this.snapshotDirty = false } return this.snapshotCache @@ -75,6 +83,31 @@ export class ClientJobOutputModel implements JobOutputSource { return () => { this.listeners.delete(listener) } } + /** + * Replace one session's roster with a `rows` frame's whole set. An empty + * set is stored as an absent key. + * @param sessionId - the watched session. + * @param jobs - the complete visible set. + */ + rowsReplaced(sessionId: SessionId, jobs: readonly JobView[]): void { + const key = String(sessionId) + if (jobs.length === 0) { + if (!this.rowsBySession.delete(key)) return + } else { + this.rowsBySession.set(key, jobs) + } + this.changed() + } + + /** + * Drop one session's roster after its last watcher stops or its stream fails. + * @param sessionId - the no-longer-watched session. + */ + rowsDropped(sessionId: SessionId): void { + if (!this.rowsBySession.delete(String(sessionId))) return + this.changed() + } + /** * The resume offset for one job's next observation generation. * @param id - observed job. @@ -99,9 +132,7 @@ export class ClientJobOutputModel implements JobOutputSource { const view: ObservedJob = { jobId: id, text: existing?.view.text ?? '', - gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, - status: frame.status, - ...frame.detail !== undefined ? { detail: frame.detail } : {}, + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.job.output.earliest || freshPastHead, streaming: true, } this.observedStates.set(String(id), { view, cursor: frame.from }) @@ -134,20 +165,15 @@ export class ClientJobOutputModel implements JobOutputSource { } /** - * Record the terminal status delivered at the end of the observation stream. + * Close the live view once the terminal `status` frame arrived: the ring is + * drained and the roster row carries the settled projection. * @param id - observed job. - * @param frame - the terminal `status` frame. */ - observeStatus(id: JobId, frame: Extract): void { + observeSettled(id: JobId): void { const state = this.observedStates.get(String(id)) /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ if (state === undefined) return - state.view = { - ...state.view, - status: frame.status, - ...frame.detail !== undefined ? { detail: frame.detail } : {}, - streaming: false, - } + state.view = { ...state.view, streaming: false } this.changed() } @@ -174,6 +200,6 @@ export class ClientJobOutputModel implements JobOutputSource { private changed(): void { this.snapshotDirty = true - notifySubscribers(this.listeners, 'job output') + notifySubscribers(this.listeners, 'jobs') } } diff --git a/packages/api/job-controller/src/client/service.ts b/packages/api/job-controller/src/client/service.ts index 07f3a00ec2..b339871180 100644 --- a/packages/api/job-controller/src/client/service.ts +++ b/packages/api/job-controller/src/client/service.ts @@ -1,7 +1,9 @@ /** - * The `ctx.jobOutput` client service: reference-counted per-job observation - * streams over `job.observe`, so overlapping viewers of one job share one - * stream and resume from the model's cursor across reconnects. + * The `ctx.jobs` client service: reference-counted streams over the `job` + * namespace — one `job.rows` roster stream per watched session and one + * `job.observe` stream per observed job — so overlapping viewers share a + * stream, rosters resume whole after a reconnect, and observations resume + * from the model's cursor. * @module @deepseek-ai/dsh-api-job-controller/client/service */ @@ -9,11 +11,18 @@ import { Service, type Context } from '@deepseek-ai/cordis' import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { JobObserveFrame, JobObserveRequest } from '../types.ts' -import type { ClientJobOutputModel, JobOutputSource } from './model.ts' +import type { JobObserveFrame, JobObserveRequest, JobRowsFrame, JobRowsRequest } from '../types.ts' +import type { ClientJobsModel, JobsSource } from './model.ts' -/** The generated `job` namespace face the observation runner drives. */ +/** The generated `job` namespace face the stream runners drive. */ export interface JobRemote { + /** + * Open one roster generation. + * @param request - the session whose visible set to mirror. + * @param signal - generation cancellation. + * @returns the whole-set frame sequence of one generation. + */ + rows(request: JobRowsRequest, signal?: AbortSignal): AsyncIterable /** * Open one observation generation. * @param request - target job, owning session, and optional resume offset. @@ -23,18 +32,25 @@ export interface JobRemote { observe(request: JobObserveRequest, signal?: AbortSignal): AsyncIterable } -/** Remote faces the observation runner drives: the Gateway stream factory and the `job` namespace. */ -export interface JobObserveRemote { +/** Remote faces the runners drive: the Gateway stream factory and the `job` namespace. */ +export interface JobsRemote { readonly $stream: ClientRemote['$stream'] readonly job: JobRemote } -/** The job-output client service face. */ -export interface IJobOutput { - /** Per-job observation state. */ - readonly state: JobOutputSource +/** The client jobs service face. */ +export interface IJobs { + /** Rosters and per-job observation state. */ + readonly state: JobsSource /** - * Start observing one job's live record; reference-counted, so two viewers + * Keep one session's roster current; reference-counted, so two watchers of + * the same session share one stream and the rows leave with the last. + * @param sessionId - the session whose visible jobs to mirror. + * @returns stop function releasing this watcher's reference. + */ + watchRows(sessionId: SessionId): () => void + /** + * Start observing one job's live output; reference-counted, so two viewers * of the same job share one stream. * @param sessionId - owning session used for the fenced read; undefined for an unowned job. * @param id - job to observe. @@ -43,8 +59,8 @@ export interface IJobOutput { observe(sessionId: SessionId | undefined, id: JobId): () => void } -/** One reference-counted observation stream. */ -interface ObservationEntry { +/** One reference-counted stream. */ +interface StreamEntry { refs: number stopped: boolean dispose: () => Promise @@ -52,79 +68,139 @@ interface ObservationEntry { declare module '@deepseek-ai/cordis' { interface Context { - /** React-free client job-record observation control. */ - jobOutput: IJobOutput + /** React-free client job rosters and observation control. */ + jobs: IJobs } } -/** Owns the bare job-output snapshot and per-job observation streams. */ -export class ClientJobOutput extends Service implements IJobOutput { - readonly state: JobOutputSource - private readonly entries = new Map() +/** Owns the bare jobs snapshot and the per-session and per-job streams. */ +export class ClientJobs extends Service implements IJobs { + readonly state: JobsSource + private readonly rowsEntries = new Map() + private readonly observations = new Map() /** * @param ctx - client root Context. * @param remote - the Gateway stream factory plus the generated `job` namespace, both resolved by the caller. - * @param model - shared client job-output model. + * @param model - shared client jobs model. */ constructor( ctx: Context, - private readonly remote: JobObserveRemote, - private readonly model: ClientJobOutputModel, + private readonly remote: JobsRemote, + private readonly model: ClientJobsModel, ) { - super(ctx, 'jobOutput') + super(ctx, 'jobs') this.state = model ctx.effect(() => async () => { - const open = [...this.entries.values()] - this.entries.clear() + const open = [...this.rowsEntries.values(), ...this.observations.values()] + this.rowsEntries.clear() + this.observations.clear() for (const entry of open) entry.stopped = true // Cordis awaits an async disposer, so the fiber stays unloading until // every carrier iterator has closed and a successor plugin instance // cannot overlap one. A carrier whose teardown fails is stopped all the // same; its failure has no consumer here. await Promise.allSettled(open.map(entry => entry.dispose())) - }, 'job-controller.client.observations') + }, 'job-controller.client.streams') + } + + watchRows(sessionId: SessionId): () => void { + return this.acquire( + this.rowsEntries, + String(sessionId), + () => this.startRows(sessionId), + () => { this.model.rowsDropped(sessionId) }, + ) } observe(sessionId: SessionId | undefined, id: JobId): () => void { - const key = String(id) - const existing = this.entries.get(key) + return this.acquire( + this.observations, + String(id), + () => this.startObservation(sessionId, id), + () => { this.model.observeStopped(id) }, + ) + } + + /** Share the live entry under `key` or start one, and hand back its release. */ + private acquire( + entries: Map, + key: string, + start: () => StreamEntry, + cleared: () => void, + ): () => void { + const existing = entries.get(key) if (existing !== undefined && !existing.stopped) { existing.refs += 1 - return this.releaser(key, existing) + return this.releaser(entries, key, existing, cleared) } - const entry = this.startObservation(sessionId, id) - this.entries.set(key, entry) - return this.releaser(key, entry) + const entry = start() + entries.set(key, entry) + return this.releaser(entries, key, entry, cleared) } /** * Release closures bind the exact entry they were minted for, never the - * map's current occupant: a later `observe()` on the same id may have - * replaced a stopped entry, and decrementing or disposing through the key - * alone would tear down that newer stream's references. + * map's current occupant: a later acquire on the same key may have replaced + * a stopped entry, and decrementing or disposing through the key alone + * would tear down that newer stream's references. */ - private releaser(key: string, entry: ObservationEntry): () => void { + private releaser(entries: Map, key: string, entry: StreamEntry, cleared: () => void): () => void { let released = false return () => { if (released) return released = true entry.refs -= 1 if (entry.refs > 0) return - if (this.entries.get(key) === entry) this.entries.delete(key) + if (entries.get(key) === entry) entries.delete(key) entry.stopped = true void entry.dispose().then(() => { - // Clear the view only while no successor observation holds the key: - // a re-expand inside the dispose round-trip already re-anchored the - // model, and a stale clear would blank its panel for good. - if (this.entries.has(key)) return - // The key is the stringified branded id this releaser was minted for. - this.model.observeStopped(key as JobId) + // Clear the state only while no successor holds the key: a re-acquire + // inside the dispose round-trip already refilled the model, and a + // stale clear would blank it for good. + if (entries.has(key)) return + cleared() }) } } - private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry { + private startRows(sessionId: SessionId): StreamEntry { + const name = `job rows ${String(sessionId)}` + const stream = this.remote.$stream({ + name, + open: signal => this.remote.job.rows({ sessionId }, signal), + // The roster has no natural end while it is watched: an end after the + // first frame is a carrier interruption (a Host reload closes the + // generation) and the next generation's whole set loses nothing. An end + // before the first frame is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before release`) + : new Error(`${name} ended before its first frame`), + }) + const entry: StreamEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + this.model.rowsReplaced(sessionId, item.value.jobs) + item.accept() + } + } catch { + // A terminal stream failure leaves nothing current to show; the model + // drops the roster rather than keeping a stale set on screen. + if (!entry.stopped) this.model.rowsDropped(sessionId) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } + + private startObservation(sessionId: SessionId | undefined, id: JobId): StreamEntry { const name = `job observation ${String(id)}` const stream = this.remote.$stream({ name, @@ -146,7 +222,7 @@ export class ClientJobOutput extends Service implements IJobOutput { ? new RemoteStreamCarrierError(`${name} ended before settlement`) : new Error(`${name} ended before its anchor`), }) - const entry: ObservationEntry = { + const entry: StreamEntry = { refs: 1, stopped: false, dispose: () => stream.dispose(), @@ -166,7 +242,7 @@ export class ClientJobOutput extends Service implements IJobOutput { } // Terminal status: leave the loop before the generation end is // classified, then close the stream for good. - this.model.observeStatus(id, frame) + this.model.observeSettled(id) break } } catch (error) { diff --git a/packages/api/job-controller/src/index.ts b/packages/api/job-controller/src/index.ts index 27e85a0ed0..431c7ce1d2 100644 --- a/packages/api/job-controller/src/index.ts +++ b/packages/api/job-controller/src/index.ts @@ -1,18 +1,18 @@ /** - * Host job Remote owner: streams one background job's observation record to - * browsers over the generated `job` namespace. The roster itself rides the - * session control stream (`jobsBySession`, owned by `dsh-api-session-controller`); - * this controller carries only the non-consuming record output. + * Host job Remote owner: streams the background-job roster one session can + * see and one job's retained output to browsers over the generated `job` + * namespace. Both streams are projections of `ctx.jobs`; the model's + * consuming cursor and notice state never observe them. * @module @deepseek-ai/dsh-api-job-controller */ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type {} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-jobs' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -import { observeJobRecord } from './observe.ts' -import type { JobObserveFrame, JobObserveRequest } from './types.ts' +import { observeJobOutput } from './observe.ts' +import { streamJobRows } from './rows.ts' +import type { JobObserveFrame, JobObserveRequest, JobRowsFrame, JobRowsRequest } from './types.ts' export type * from './types.ts' @@ -23,7 +23,7 @@ declare module '@deepseek-ai/cordis' { } } -/** Default coalescing window between record reads, in milliseconds. */ +/** Default coalescing window between reads, in milliseconds. */ const DEFAULT_OBSERVE_FLUSH_MS = 100 /** Default soft byte budget per output frame. */ @@ -31,7 +31,7 @@ const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024 /** Job Controller deployment policy. */ export interface Config { - /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */ + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ readonly observeFlushMs?: number /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ readonly observeMaxFrameBytes?: number @@ -39,7 +39,7 @@ export interface Config { /** Host service backing the generated `ctx.remote.job` namespace. */ export class JobController extends TypertRemoteService { - static inject = ['agents', 'jobs', 'typert'] + static inject = ['jobs', 'typert'] static Config: z = z.object({ observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), @@ -50,7 +50,7 @@ export class JobController extends TypertRemoteService { private readonly observeMaxFrameBytes: number /** - * @param ctx - Host context carrying the live Agent registry and the job registry. + * @param ctx - Host context carrying the job registry. * @param config - observation cadence and framing policy. */ constructor(ctx: Context, config: Config) { @@ -63,22 +63,33 @@ export class JobController extends TypertRemoteService { } /** - * Stream one job's retained record output from an absolute byte offset, - * then its terminal status once settled and drained. Non-consuming: the + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ + @Remote({ mode: 'stream' }) + rows(request: JobRowsRequest, signal: AbortSignal): AsyncIterable { + return streamJobRows(this.ctx.jobs, request, { flushMs: this.observeFlushMs }, signal) + } + + /** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the * model-facing cursor and notice state never observe these reads. The - * request's session resolves the fenced-read caller; the registry rejects a - * job the session does not own, a job without a record, or an unknown job. + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. * @param request - target job, owning session, and optional resume offset. * @param signal - cancellation owned by the Remote stream carrier. * @returns anchor, coalesced output frames, and the terminal status. */ @Remote({ mode: 'stream' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable { - const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId) - return observeJobRecord(this.ctx.jobs, request, { + return observeJobOutput(this.ctx.jobs, request, { flushMs: this.observeFlushMs, maxFrameBytes: this.observeMaxFrameBytes, - caller, }, signal) } } diff --git a/packages/api/job-controller/src/observe.ts b/packages/api/job-controller/src/observe.ts index 731f40b2e2..8590a704f7 100644 --- a/packages/api/job-controller/src/observe.ts +++ b/packages/api/job-controller/src/observe.ts @@ -1,9 +1,9 @@ -/** Per-job record observation generations: anchor, coalesced output, terminal status. */ +/** Per-job observation generations: anchor, coalesced output, terminal status. */ -import type { Agent } from '@deepseek-ai/dsh-agent' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -import type { JobRegistry, JobSnapshot } from '@deepseek-ai/dsh-jobs' -import type { JobObserveFrame, JobObserveRequest, JobWireChunk } from './types.ts' +import type { JobChunk, JobRegistry, JobStatus } from '@deepseek-ai/dsh-jobs' +import type { JobObserveFrame, JobObserveRequest } from './types.ts' +import { OutputWaiter, sleep } from './wake.ts' /** Cadence and framing bounds for one observation generation. */ export interface ObserveJobOptions { @@ -15,81 +15,28 @@ export interface ObserveJobOptions { * registry's retention cap is the hard bound on any single chunk). */ readonly maxFrameBytes: number - /** The live owning agent for fenced reads, resolved from the request's session. */ - readonly caller: Agent | undefined } -function isTerminal(status: JobSnapshot['status']): boolean { +function isTerminal(status: JobStatus): boolean { return status !== 'running' && status !== 'stopping' } -/** A record job's total offset; the anchor check proved the declaration and it never leaves. */ -function recordTotal(snapshot: JobSnapshot): number { - /* v8 ignore next -- a declared record never loses its offsets. */ - return snapshot.outputTotal ?? 0 -} - -/** Wake-flag waiter: a wake between waits is never lost. */ -class OutputWaiter { - private dirty = false - private resolve: (() => void) | undefined - - wake(): void { - this.dirty = true - this.resolve?.() - } - - /** - * Resolve on the next wake, immediately when one already arrived, or on abort. - * @param signal - generation cancellation. - */ - wait(signal: AbortSignal): Promise { - if (this.dirty || signal.aborted) { - this.dirty = false - return Promise.resolve() - } - return new Promise((resolve) => { - const finish = (): void => { - signal.removeEventListener('abort', finish) - /* v8 ignore next -- one wait owns the sole installed resolver. */ - if (this.resolve === finish) this.resolve = undefined - this.dirty = false - resolve() - } - this.resolve = finish - signal.addEventListener('abort', finish, { once: true }) - }) - } -} - -function sleep(ms: number, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve() - return new Promise((resolve) => { - const timer = setTimeout(done, ms) - function done(): void { - clearTimeout(timer) - signal.removeEventListener('abort', done) - resolve() - } - signal.addEventListener('abort', done, { once: true }) - }) -} - /** - * Stream one job's retained record output from an absolute offset: one - * `opened` anchor, coalesced `output` frames as the record advances, then one - * terminal `status` after the settled job is drained, after which the - * generation closes normally. Reads are non-consuming — the model-facing - * cursor and notice state never observe them; reconnecting callers resume by - * passing the last frame's `next` as `from`. Throws for a job that declared - * no record. + * Stream one job's retained output from an absolute offset: one `opened` + * anchor, coalesced `output` frames as the ring advances, then one terminal + * `status` after the settled job is drained, after which the generation + * closes normally. Reads are non-consuming — the model-facing cursor and + * notice state never observe them; reconnecting callers resume by passing + * the last frame's `next` as `from`. The request's session is the fenced + * read's caller: the registry rejects a job the session cannot see and an + * unknown job. * @param registry - the live job registry. - * @param request - target job and optional resume offset. - * @param options - cadence, framing bounds, and the fenced-read caller. + * @param request - target job, owning session, and optional resume offset. + * @param options - cadence and framing bounds. * @param signal - generation cancellation owned by the Remote stream carrier. * @returns the observation frame sequence for one generation. */ -export async function* observeJobRecord( +export async function* observeJobOutput( registry: JobRegistry, request: JobObserveRequest, options: ObserveJobOptions, @@ -105,41 +52,24 @@ export async function* observeJobRecord( const waiter = new OutputWaiter() // Subscribe before the first read so an append between the anchor read and // the wait cannot be missed. - const unsubscribe = registry.onOutput((changed) => { + const unsubscribe = registry.events.subscribe({ owners: 'all' }, (event) => { + const changed = event.type === 'output' ? event.id : event.job.id if (changed === id) waiter.wake() }) try { - const caller = options.caller - let snapshot = registry.get(id, caller) - if (snapshot.outputTotal === undefined || snapshot.outputEarliest === undefined) { - throw new Error(`job ${id} declared no output record`) - } - let cursor = request.from ?? snapshot.outputEarliest - yield { - type: 'opened', - jobId: id, - from: cursor, - earliest: snapshot.outputEarliest, - total: snapshot.outputTotal, - status: snapshot.status, - ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, - } + const visible = registry.visibleTo(request.sessionId) + let job = visible.get(id) + let cursor = request.from ?? job.output.earliest + yield { type: 'opened', job, from: cursor } while (!signal.aborted) { - const read = registry.readRecord(id, cursor, caller) - if (read.chunks.length > 0) { + const read = visible.readAt(id, cursor) + if (read.chunks.length > 0 || read.lossy) { yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes) } cursor = read.next - snapshot = registry.get(id, caller) - if (isTerminal(snapshot.status) && cursor >= recordTotal(snapshot)) { - yield { - type: 'status', - status: snapshot.status, - ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, - // Settlement always stamps finishedAt; the guard only discharges the optional field type. - /* v8 ignore next */ - ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, - } + job = visible.get(id) + if (isTerminal(job.status) && cursor >= job.output.total) { + yield { type: 'status', job } return } await waiter.wait(signal) @@ -153,12 +83,12 @@ export async function* observeJobRecord( /** Split one read into frames along the soft per-frame byte budget. */ function* outputFrames( - chunks: readonly JobWireChunk[], + chunks: readonly JobChunk[], next: number, lossy: boolean, maxFrameBytes: number, ): Iterable { - let batch: JobWireChunk[] = [] + let batch: JobChunk[] = [] let batchBytes = 0 let flaggedLossy = lossy for (const chunk of chunks) { diff --git a/packages/api/job-controller/src/rows.ts b/packages/api/job-controller/src/rows.ts new file mode 100644 index 0000000000..9076f12424 --- /dev/null +++ b/packages/api/job-controller/src/rows.ts @@ -0,0 +1,54 @@ +/** Per-session roster generations: the caller-visible job set, replaced whole after every lifecycle change. */ + +import type { JobRegistry } from '@deepseek-ai/dsh-jobs' +import type { JobRowsFrame, JobRowsRequest } from './types.ts' +import { OutputWaiter, sleep } from './wake.ts' + +/** Cadence bound for one roster generation. */ +export interface RowsOptions { + /** Coalescing window after a lifecycle event before the next rows read, in milliseconds. */ + readonly flushMs: number +} + +/** + * Stream the jobs one session can see: one frame on open, then one after + * every lifecycle commit that touches a visible job (registration, progress, + * stopping, settlement, removal), coalesced over `flushMs`. Output appends + * never refresh the roster — a settled projection already carries the final + * byte count — so the stream is quiet while a job merely writes. Reads are + * projections; the model's cursor and notice state never observe them. + * @param registry - the live job registry. + * @param request - the session whose visible set to mirror. + * @param options - the coalescing window. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the roster frame sequence for one generation. + */ +export async function* streamJobRows( + registry: JobRegistry, + request: JobRowsRequest, + options: RowsOptions, + signal: AbortSignal, +): AsyncIterable { + signal.throwIfAborted() + const waiter = new OutputWaiter() + // Subscribe before the first read so a commit between the read and the + // wait cannot be missed. + const unsubscribe = registry.events.subscribe({ owners: 'all' }, (event) => { + if (event.type === 'output') return + const owner = event.job.owner + if (owner === undefined || owner === request.sessionId) waiter.wake() + }) + try { + const visible = registry.visibleTo(request.sessionId) + yield { type: 'rows', jobs: visible.list() } + while (true) { + await waiter.wait(signal) + // Let a burst of commits settle into one frame. + await sleep(options.flushMs, signal) + if (signal.aborted) return + yield { type: 'rows', jobs: visible.list() } + } + } finally { + unsubscribe() + } +} diff --git a/packages/api/job-controller/src/types.ts b/packages/api/job-controller/src/types.ts index 98df4d8f65..0afa3ba1e9 100644 --- a/packages/api/job-controller/src/types.ts +++ b/packages/api/job-controller/src/types.ts @@ -1,24 +1,35 @@ /** - * Wire types of the generated `job` Remote namespace: the observation request - * and the frames its stream yields. Client-safe: no Host imports. + * Wire types of the generated `job` Remote namespace: the per-session roster + * stream and the per-job observation stream. Client-safe: no Host imports. * @module @deepseek-ai/dsh-api-job-controller/types */ import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { JobChunk, JobView } from '@deepseek-ai/dsh-jobs/view' import type { SessionId } from '@deepseek-ai/dsh-session/types' +export type { JobChunk, JobView } from '@deepseek-ai/dsh-jobs/view' + +/** Target of one `job.rows` stream: the session whose visible jobs the stream mirrors. */ +export interface JobRowsRequest { + readonly sessionId: SessionId +} + /** - * A job's lifecycle state on the wire. Spelled out rather than imported from - * the registry package so this client-safe face pulls no Host declaration - * merges into browser programs; it is the registry's `JobStatus` union. + * One `job.rows` frame: the complete set the session can see — its own jobs + * plus every unowned job — after a lifecycle change. Whole-set replacement, + * so a reconnect's first frame is already the truth. */ -export type JobObserveStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed' +export interface JobRowsFrame { + readonly type: 'rows' + readonly jobs: readonly JobView[] +} /** Target of one `job.observe` stream: the job, its owning session, and an optional resume offset. */ export interface JobObserveRequest { /** - * Owning session, resolved to the live agent for the fenced record read. - * Omitted for an unowned job, which any caller may observe. + * Owning session for the fenced read. Omitted for an unowned job, which any + * caller may observe. */ readonly sessionId?: SessionId readonly jobId: JobId @@ -29,39 +40,25 @@ export interface JobObserveRequest { readonly from?: number } -/** One retained record chunk on the wire. `channel` widens to string like a job's `kind`. */ -export interface JobWireChunk { - /** Absolute offset of the chunk's first byte. */ - readonly at: number - readonly text: string - readonly channel?: string - /** Bytes immediately before this chunk were lost at the producer or to retention. */ - readonly gapBefore?: true -} - /** - * Job observation stream frames: one `opened` anchor, then coalesced `output` - * batches, then — once the job has settled and its retained record is - * drained — one terminal `status`, after which the stream closes normally. - * Status rides the same stream as output so settlement can never race a - * still-open output channel. + * Job observation stream frames: one `opened` anchor carrying the job's + * projection (its `output.earliest` and `output.total` at open time) and the + * offset the first `output` frame continues from, then coalesced `output` + * batches, then — once the job has settled and its ring is drained — one + * terminal `status` carrying the settled projection, after which the stream + * closes normally. Status rides the same stream as output so settlement can + * never race a still-open output channel. */ export type JobObserveFrame = | { readonly type: 'opened' - readonly jobId: JobId + readonly job: JobView /** Offset the first `output` frame continues from. */ readonly from: number - /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ - readonly earliest: number - /** Total bytes produced at open time. */ - readonly total: number - readonly status: JobObserveStatus - readonly detail?: string } | { readonly type: 'output' - readonly chunks: readonly JobWireChunk[] + readonly chunks: readonly JobChunk[] /** Offset to resume from after this frame. */ readonly next: number /** Bytes between the requested offset and `chunks` were already evicted. */ @@ -69,7 +66,5 @@ export type JobObserveFrame = } | { readonly type: 'status' - readonly status: JobObserveStatus - readonly detail?: string - readonly finishedAt?: number + readonly job: JobView } diff --git a/packages/api/job-controller/src/wake.ts b/packages/api/job-controller/src/wake.ts new file mode 100644 index 0000000000..489699cb3c --- /dev/null +++ b/packages/api/job-controller/src/wake.ts @@ -0,0 +1,60 @@ +/** + * Wake bookkeeping shared by the two observation generators: a wake-flag + * waiter that never loses a wake between waits, and an abortable sleep that + * coalesces bursts into bounded frames. + * @module @deepseek-ai/dsh-api-job-controller/wake + */ + +/** Wake-flag waiter: a wake between waits is never lost. */ +export class OutputWaiter { + private dirty = false + private resolve: (() => void) | undefined + + /** Record one wake; releases a pending wait or arms the next one. */ + wake(): void { + this.dirty = true + this.resolve?.() + } + + /** + * Resolve on the next wake, immediately when one already arrived, or on abort. + * @param signal - generation cancellation. + * @returns settles when woken or aborted. + */ + wait(signal: AbortSignal): Promise { + if (this.dirty || signal.aborted) { + this.dirty = false + return Promise.resolve() + } + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one wait owns the sole installed resolver. */ + if (this.resolve === finish) this.resolve = undefined + this.dirty = false + resolve() + } + this.resolve = finish + signal.addEventListener('abort', finish, { once: true }) + }) + } +} + +/** + * Sleep for the coalescing window, or return at once when aborted. + * @param ms - window in milliseconds. + * @param signal - generation cancellation. + * @returns settles after the window or on abort. + */ +export function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} diff --git a/packages/api/job-controller/tests/client-apply.client.spec.ts b/packages/api/job-controller/tests/client-apply.client.spec.ts index ebbe67e98e..37fb4c3048 100644 --- a/packages/api/job-controller/tests/client-apply.client.spec.ts +++ b/packages/api/job-controller/tests/client-apply.client.spec.ts @@ -4,7 +4,7 @@ import { RemoteStream, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gat import type { SessionId } from '@deepseek-ai/dsh-session/types' import { afterEach, describe, expect, it } from 'vitest' import * as JobClient from '../src/client/index.ts' -import type { JobObserveFrame } from '../src/types.ts' +import type { JobObserveFrame, JobRowsFrame } from '../src/types.ts' const contexts = new Set() @@ -14,10 +14,11 @@ afterEach(async () => { }) /** Mount the client half over a captured `remote.job` namespace and a real Gateway stream carrier. */ -async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> { +async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCalls: unknown[] }> { const ctx = new Context() contexts.add(ctx) const observeCalls: unknown[] = [] + const rowsCalls: unknown[] = [] const connection: ConnectionHandle = { isLoopback: true, generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} }, @@ -28,7 +29,11 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> { start: () => ({ stop: () => {} }), } const job = { - // The fake stream never yields; opening and releasing must leave no view behind. + // The fake streams never yield; opening and releasing must leave no state behind. + rows: (request: unknown) => { + rowsCalls.push(request) + return (async function* (): AsyncGenerator {})() + }, observe: (request: unknown) => { observeCalls.push(request) return (async function* (): AsyncGenerator {})() @@ -40,7 +45,7 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> { }) ctx.reflect.provide('remote.job', job) await ctx.plugin(JobClient) - return { ctx, observeCalls } + return { ctx, observeCalls, rowsCalls } } async function flush(): Promise { @@ -52,14 +57,17 @@ describe('Job Controller Client apply', () => { expect(JobClient.inject).toEqual(['remote', 'remote.job']) }) - it('installs ctx.jobOutput over the captured job namespace', async () => { - const { ctx, observeCalls } = await mount() - expect(ctx.jobOutput).toBeDefined() - const stop = ctx.jobOutput.observe('session-1' as SessionId, 'bash-1' as never) + it('installs ctx.jobs over the captured job namespace', async () => { + const { ctx, observeCalls, rowsCalls } = await mount() + expect(ctx.jobs).toBeDefined() + const stopRows = ctx.jobs.watchRows('session-1' as SessionId) + const stopObserve = ctx.jobs.observe('session-1' as SessionId, 'bash-1' as never) await flush() + expect(rowsCalls).toEqual([{ sessionId: 'session-1' }]) expect(observeCalls).toEqual([{ sessionId: 'session-1', jobId: 'bash-1' }]) - stop() + stopRows() + stopObserve() await flush() - expect(ctx.jobOutput.state.getSnapshot().observed).toEqual({}) + expect(ctx.jobs.state.getSnapshot()).toEqual({ rows: {}, observed: {} }) }) }) diff --git a/packages/api/job-controller/tests/host-harness.ts b/packages/api/job-controller/tests/host-harness.ts new file mode 100644 index 0000000000..0ffb1fd2b8 --- /dev/null +++ b/packages/api/job-controller/tests/host-harness.ts @@ -0,0 +1,92 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { JobAppendOptions, JobHandle, JobOutcome } from '@deepseek-ai/dsh-jobs' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import { Session, SessionId } from '@deepseek-ai/dsh-session' + +/** A registry with a tiny ring so eviction is cheap to reach. */ +export async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry, { retainBytes: 64, settledRetainBytes: 64 }) + ctx.jobs.attachController('job-controller-test') + return ctx +} + +/** Register a live agent for `rawId` under its own lifecycle scope. */ +export function registerAgent(ctx: Context, rawId: string): Agent & { disposeScope: () => Promise } { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(rawId) + const session = Session.create(id) + const agent = { + id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scopeFiber.ctx, + disposeScope: () => scopeFiber.dispose(), + } as unknown as Agent & { disposeScope: () => Promise } + ctx.agents.register(agent) + return agent +} + +/** + * Start one push-producer job whose `done` settles on demand; expose the + * handle's writers. A cancel settles the job as killed unless `slowStop` + * keeps it stopping until the test settles it. + */ +export function startJob(ctx: Context, options: { label?: string; owner?: SessionId; slowStop?: boolean } = {}) { + let settle!: (outcome: JobOutcome) => void + let handle!: JobHandle + const id = ctx.jobs.start({ + kind: 'bash', + label: options.label ?? 'echo', + ...options.owner !== undefined ? { owner: options.owner } : {}, + run: (job) => { + handle = job + return { + cancel() { if (options.slowStop !== true) settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + } + }, + }) + return { + id, + append: (text: string, opts?: JobAppendOptions) => { handle.append(text, opts) }, + progress: (line: string) => { handle.updateProgress(line) }, + settle: async (outcome: JobOutcome) => { + settle(outcome) + await new Promise(resolve => setTimeout(resolve, 0)) + }, + } +} + +/** Read frames until `count` arrive or the generator finishes, then abort. */ +export async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: Frame[] = [] + const iterator = iterable[Symbol.asyncIterator]() + try { + while (frames.length < count) { + const step = await iterator.next() + if (step.done) return frames + frames.push(step.value) + } + } finally { + abort.abort() + await iterator.next().catch(() => undefined) + } + return frames +} + +export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +/** The next yielded frame, or undefined once the generator finished. */ +export async function next(iterator: AsyncIterator): Promise { + const step = await iterator.next() + return step.done === true ? undefined : step.value +} diff --git a/packages/api/job-controller/tests/model.client.spec.ts b/packages/api/job-controller/tests/model.client.spec.ts index 5e49771318..a170f1b98f 100644 --- a/packages/api/job-controller/tests/model.client.spec.ts +++ b/packages/api/job-controller/tests/model.client.spec.ts @@ -2,44 +2,75 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { ClientJobOutputModel } from '../src/client/model.ts' -import { ClientJobOutput } from '../src/client/service.ts' -import type { JobObserveFrame } from '../src/types.ts' +import { ClientJobsModel } from '../src/client/model.ts' +import { ClientJobs } from '../src/client/service.ts' +import type { JobObserveFrame, JobRowsFrame, JobView } from '../src/types.ts' const ID = 'bash-1' as JobId +const S1 = 'alice' as SessionId +const S2 = 'bob' as SessionId -function opened(model: ClientJobOutputModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { +function view(over: Partial = {}): JobView { + return { + id: ID, kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, + output: { total: 0, earliest: 0 }, + ...over, + } +} + +function opened(model: ClientJobsModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { model.observeOpened(ID, { type: 'opened', - jobId: ID, - from: 0, - earliest: 0, - total: 0, - status: 'running', - ...over, + job: view({ output: { total: over.total ?? 0, earliest: over.earliest ?? 0 } }), + from: over.from ?? 0, }) } -describe('ClientJobOutputModel observation', () => { - it('accumulates output, advances the resume cursor, and settles on status', () => { - const model = new ClientJobOutputModel() +describe('ClientJobsModel rows', () => { + it('replaces a session roster whole, stores an empty set as absence, and drops on release', () => { + const model = new ClientJobsModel() + let notified = 0 + model.subscribe(() => { notified += 1 }) + model.rowsReplaced(S1, [view()]) + model.rowsReplaced(S2, [view({ id: 'pwsh-1' as JobId, label: 'other' })]) + expect(model.getSnapshot().rows[S1]).toEqual([view()]) + expect(model.getSnapshot().rows[S2]?.[0]?.label).toBe('other') + + // Last-wins: the newer whole set replaces, it does not merge. + model.rowsReplaced(S1, [view({ status: 'completed' })]) + expect(model.getSnapshot().rows[S1]).toEqual([view({ status: 'completed' })]) + expect(notified).toBe(3) + + model.rowsReplaced(S1, []) + expect(S1 in model.getSnapshot().rows).toBe(false) + // An empty set over an absent key changes nothing. + model.rowsReplaced(S1, []) + expect(notified).toBe(4) + + model.rowsDropped(S2) + expect(model.getSnapshot().rows).toEqual({}) + model.rowsDropped(S2) + expect(notified).toBe(5) + }) +}) + +describe('ClientJobsModel observation', () => { + it('accumulates output, advances the resume cursor, and closes on settlement', () => { + const model = new ClientJobsModel() opened(model) expect(model.cursorOf(ID)).toBe(0) model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 }) model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 }) expect(model.cursorOf(ID)).toBe(3) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.text).toBe('abc') - expect(view?.streaming).toBe(true) - model.observeStatus(ID, { type: 'status', status: 'completed', detail: 'exit code: 0' }) - const settled = model.getSnapshot().observed[String(ID)] - expect(settled?.streaming).toBe(false) - expect(settled?.status).toBe('completed') - expect(settled?.detail).toBe('exit code: 0') + const live = model.getSnapshot().observed[String(ID)] + expect(live?.text).toBe('abc') + expect(live?.streaming).toBe(true) + model.observeSettled(ID) + expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) }) it('keeps snapshot identity stable between changes and notifies subscribers', () => { - const model = new ClientJobOutputModel() + const model = new ClientJobsModel() let notified = 0 const unsubscribe = model.subscribe(() => { notified += 1 }) const before = model.getSnapshot() @@ -53,16 +84,16 @@ describe('ClientJobOutputModel observation', () => { }) it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => { - const model = new ClientJobOutputModel() + const model = new ClientJobsModel() opened(model, { from: 4, earliest: 8, total: 10 }) expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - const clean = new ClientJobOutputModel() + const clean = new ClientJobsModel() opened(clean) clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 }) expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - const lossy = new ClientJobOutputModel() + const lossy = new ClientJobsModel() opened(lossy) lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true }) expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) @@ -71,12 +102,12 @@ describe('ClientJobOutputModel observation', () => { it('marks a fresh observation anchored past the evicted head', () => { // Fresh observations anchor at the registry's earliest retained byte, so // from === earliest > 0 means the head was already discarded. - const fresh = new ClientJobOutputModel() + const fresh = new ClientJobsModel() opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 }) expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) // A retry that accumulated no text yet earns the mark the same way. - const retried = new ClientJobOutputModel() + const retried = new ClientJobsModel() opened(retried) expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false) opened(retried, { from: 6, earliest: 6, total: 6 }) @@ -84,43 +115,40 @@ describe('ClientJobOutputModel observation', () => { }) it('bounds the render tail without splitting a surrogate pair', () => { - const model = new ClientJobOutputModel() + const model = new ClientJobsModel() opened(model) const emoji = '😀'.repeat((64 * 1024) + 8) model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 }) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.gapBefore).toBe(true) - expect(view!.text.length).toBeLessThanOrEqual(128 * 1024) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.gapBefore).toBe(true) + expect(live!.text.length).toBeLessThanOrEqual(128 * 1024) // The bound landed between pairs: the surviving text still round-trips. - expect(/^(?:😀)+$/u.test(view!.text)).toBe(true) + expect(/^(?:😀)+$/u.test(live!.text)).toBe(true) }) - it('carries the anchor detail and trims a plain-ASCII tail without a boundary shift', () => { - const model = new ClientJobOutputModel() - model.observeOpened(ID, { - type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running', detail: 'exit soon', - }) - expect(model.getSnapshot().observed[String(ID)]?.detail).toBe('exit soon') + it('trims a plain-ASCII tail without a boundary shift', () => { + const model = new ClientJobsModel() + opened(model) const long = 'x'.repeat((128 * 1024) + 5) model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length }) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.text.length).toBe(128 * 1024) - expect(view?.gapBefore).toBe(true) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.text.length).toBe(128 * 1024) + expect(live?.gapBefore).toBe(true) }) it('advances the cut past a low surrogate landing exactly on the bound', () => { - const model = new ClientJobOutputModel() + const model = new ClientJobsModel() opened(model) // 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index. const text = 'z😀' + 'a'.repeat((128 * 1024) - 1) model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length }) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.text.length).toBe((128 * 1024) - 1) - expect(view?.text.startsWith('a')).toBe(true) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.text.length).toBe((128 * 1024) - 1) + expect(live?.text.startsWith('a')).toBe(true) }) it('preserves accumulated text across a reconnect anchor and clears on stop', () => { - const model = new ClientJobOutputModel() + const model = new ClientJobsModel() opened(model) model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 }) opened(model, { from: 4, earliest: 0, total: 4 }) @@ -133,19 +161,19 @@ describe('ClientJobOutputModel observation', () => { }) it('records a terminal stream failure on the live view', () => { - const model = new ClientJobOutputModel() + const model = new ClientJobsModel() opened(model) model.observeFailed(ID, new Error('carrier gone')) - const view = model.getSnapshot().observed[String(ID)] - expect(view?.streaming).toBe(false) - expect(view?.error).toContain('carrier gone') + const live = model.getSnapshot().observed[String(ID)] + expect(live?.streaming).toBe(false) + expect(live?.error).toContain('carrier gone') // A failure for an untracked id is inert. model.observeFailed('bash-9' as JobId, new Error('ignored')) }) }) /** One scripted logical stream: frames are pushed by the test, never reopened. */ -class FakeStream { +class FakeStream { disposed = false /** Model a carrier whose disposal surfaces as an iterator throw. */ throwOnDispose = false @@ -153,12 +181,12 @@ class FakeStream { deferDispose = false private disposeRelease: (() => void) | undefined private disposePending: Promise | undefined - private readonly frames: JobObserveFrame[] = [] + private readonly frames: Frame[] = [] private waiter: (() => void) | undefined private failure: Error | undefined readonly accepts: number[] = [] - push(frame: JobObserveFrame): void { + push(frame: Frame): void { this.frames.push(frame) this.waiter?.() } @@ -208,14 +236,21 @@ class FakeStream { } } +interface StreamOptions { + name: string + open: (signal: AbortSignal) => unknown + ended: (accepted: boolean) => Error +} + function bench() { const ctx = new Context() - const model = new ClientJobOutputModel() - const streams: { options: { open: (signal: AbortSignal) => unknown }; stream: FakeStream }[] = [] + const model = new ClientJobsModel() + const streams: { options: StreamOptions; stream: FakeStream & FakeStream }[] = [] const observeCalls: unknown[] = [] + const rowsCalls: unknown[] = [] const remote = { - $stream: (options: { open: (signal: AbortSignal) => unknown }) => { - const stream = new FakeStream() + $stream: (options: StreamOptions) => { + const stream = new FakeStream() streams.push({ options, stream }) return stream }, @@ -224,37 +259,129 @@ function bench() { observeCalls.push(request) return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } }, + rows: (request: unknown) => { + rowsCalls.push(request) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, }, } - const output = new ClientJobOutput(ctx, remote as never, model) - return { ctx, model, output, streams, observeCalls } + const jobs = new ClientJobs(ctx, remote as never, model) + return { ctx, model, jobs, streams, observeCalls, rowsCalls } } const tick = () => new Promise(resolve => setTimeout(resolve, 0)) +const anchor = (): JobObserveFrame => ({ type: 'opened', job: view(), from: 0 }) + +describe('ClientJobs roster streams', () => { + it('shares one roster stream per session and drops the rows after the last release', async () => { + const { model, jobs, streams, rowsCalls } = bench() + const first = jobs.watchRows(S1) + const second = jobs.watchRows(S1) + jobs.watchRows(S2) + expect(streams.map(entry => entry.options.name)).toEqual(['job rows alice', 'job rows bob']) + streams[0]!.options.open(new AbortController().signal) + expect(rowsCalls.at(-1)).toEqual({ sessionId: 'alice' }) -describe('ClientJobOutput observation streams', () => { - it('feeds anchor, output, and terminal frames into the model, then closes', async () => { - const { model, output, streams } = bench() - output.observe(undefined, ID) const { stream } = streams[0]! - stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push({ type: 'rows', jobs: [view()] }) + await tick() + expect(model.getSnapshot().rows[S1]).toEqual([view()]) + expect(stream.accepts).toHaveLength(1) + stream.push({ type: 'rows', jobs: [view({ status: 'completed' })] }) + await tick() + expect(model.getSnapshot().rows[S1]?.[0]?.status).toBe('completed') + + first() + first() + expect(stream.disposed).toBe(false) + second() + await tick() + expect(stream.disposed).toBe(true) + expect(S1 in model.getSnapshot().rows).toBe(false) + expect(streams[1]!.stream.disposed).toBe(false) + }) + + it('classifies a roster end as retryable only after the first frame', () => { + const { jobs, streams } = bench() + jobs.watchRows(S1) + const { options } = streams[0]! + expect(options.ended(true).name).toBe('RemoteStreamCarrierError') + expect(options.ended(false).name).toBe('Error') + }) + + it('drops the roster on a terminal stream failure', async () => { + const { model, jobs, streams } = bench() + jobs.watchRows(S1) + const { stream } = streams[0]! + stream.push({ type: 'rows', jobs: [view()] }) + await tick() + expect(model.getSnapshot().rows[S1]).toBeDefined() + stream.poison(new Error('frame decode broke')) + await tick() + expect(S1 in model.getSnapshot().rows).toBe(false) + }) + + it('stays silent when the carrier teardown itself throws after the last release', async () => { + const { model, jobs, streams } = bench() + const stop = jobs.watchRows(S1) + const { stream } = streams[0]! + stream.throwOnDispose = true + stream.push({ type: 'rows', jobs: [view()] }) + await tick() + // The releaser drops the rows and marks the entry stopped, then its + // dispose surfaces as an iterator throw — which the consumer swallows for + // a stopped entry instead of dropping twice or failing loud. + stop() + await tick() + expect(model.getSnapshot().rows).toEqual({}) + }) + + it('a re-watch inside the dispose round-trip keeps its fresh rows', async () => { + const { model, jobs, streams } = bench() + const stop = jobs.watchRows(S1) + const first = streams[0]!.stream + first.deferDispose = true + first.push({ type: 'rows', jobs: [view()] }) + await tick() + + stop() + // Re-watch while the previous generation's dispose is still in flight. + jobs.watchRows(S1) + const second = streams[1]!.stream + second.push({ type: 'rows', jobs: [view({ label: 'fresh' })] }) + await tick() + + first.releaseDispose() + await tick() + // The stale post-dispose clear must not blank the successor's rows. + expect(model.getSnapshot().rows[S1]?.[0]?.label).toBe('fresh') + }) +}) + +describe('ClientJobs observation streams', () => { + it('feeds anchor, output, and terminal frames into the model, then closes', async () => { + const { model, jobs, streams } = bench() + jobs.observe(undefined, ID) + const { stream } = streams[0]! + expect(streams[0]!.options.name).toBe('job observation bash-1') + stream.push(anchor()) stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 }) await tick() expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi') expect(stream.accepts).toHaveLength(1) - stream.push({ type: 'status', status: 'completed' }) + stream.push({ type: 'status', job: view({ status: 'completed' }) }) await tick() expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) expect(stream.disposed).toBe(true) }) it('shares one stream across observers and stops after the last release', async () => { - const { model, output, streams } = bench() - const first = output.observe(undefined, ID) - const second = output.observe(undefined, ID) + const { model, jobs, streams } = bench() + const first = jobs.observe(undefined, ID) + const second = jobs.observe(undefined, ID) expect(streams).toHaveLength(1) - streams[0]!.stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + streams[0]!.stream.push(anchor()) await tick() first() @@ -267,17 +394,17 @@ describe('ClientJobOutput observation streams', () => { }) it('a late release of a superseded observation neither tears down nor clears its successor', async () => { - const { model, output, streams } = bench() - const firstStop = output.observe(undefined, ID) + const { model, jobs, streams } = bench() + const firstStop = jobs.observe(undefined, ID) const first = streams[0]!.stream - first.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - first.push({ type: 'status', status: 'completed' }) + first.push(anchor()) + first.push({ type: 'status', job: view({ status: 'completed' }) }) await tick() // A successor observation replaces the settled entry while the first // observer still holds its releaser. - const secondStop = output.observe(undefined, ID) + const secondStop = jobs.observe(undefined, ID) const second = streams[1]!.stream - second.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + second.push(anchor()) await tick() firstStop() @@ -292,18 +419,18 @@ describe('ClientJobOutput observation streams', () => { }) it('a re-observation inside the dispose round-trip keeps its fresh view', async () => { - const { model, output, streams } = bench() - const stop = output.observe(undefined, ID) + const { model, jobs, streams } = bench() + const stop = jobs.observe(undefined, ID) const first = streams[0]!.stream first.deferDispose = true - first.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + first.push(anchor()) await tick() stop() // Re-expand while the previous generation's dispose is still in flight. - output.observe(undefined, ID) + jobs.observe(undefined, ID) const second = streams[1]!.stream - second.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + second.push(anchor()) await tick() first.releaseDispose() @@ -316,9 +443,9 @@ describe('ClientJobOutput observation streams', () => { }) it('opens a fresh stream for a re-observed job and resumes from the model cursor', async () => { - const { output, streams, observeCalls, model } = bench() - const stop = output.observe('alice' as SessionId, ID) - streams[0]!.stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + const { jobs, streams, observeCalls, model } = bench() + const stop = jobs.observe(S1, ID) + streams[0]!.stream.push(anchor()) streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 }) await tick() // The generation opener reads the live cursor at open time and carries the @@ -328,7 +455,7 @@ describe('ClientJobOutput observation streams', () => { stop() await tick() - output.observe(undefined, ID) + jobs.observe(undefined, ID) expect(streams).toHaveLength(2) streams[1]!.options.open(new AbortController().signal) // Stopped observation dropped the cursor: the fresh stream starts unanchored. @@ -337,31 +464,31 @@ describe('ClientJobOutput observation streams', () => { }) it('classifies a premature end as retryable only after the anchor', () => { - const { output, streams } = bench() - output.observe(undefined, ID) - const options = streams[0]!.options as unknown as { ended: (accepted: boolean) => Error } + const { jobs, streams } = bench() + jobs.observe(undefined, ID) + const { options } = streams[0]! expect(options.ended(true).name).toBe('RemoteStreamCarrierError') expect(options.ended(false).name).toBe('Error') }) it('records a consumer failure on the live view', async () => { - const { model, output, streams } = bench() - output.observe(undefined, ID) + const { model, jobs, streams } = bench() + jobs.observe(undefined, ID) const { stream } = streams[0]! - stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push(anchor()) await tick() stream.poison(new Error('frame decode broke')) await tick() - const view = model.getSnapshot().observed[String(ID)] - expect(view?.error).toContain('frame decode broke') - expect(view?.streaming).toBe(false) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.error).toContain('frame decode broke') + expect(live?.streaming).toBe(false) }) it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => { - const { ctx, model, output, streams } = bench() - const stop = output.observe(undefined, ID) + const { ctx, model, jobs, streams } = bench() + const stop = jobs.observe(undefined, ID) const { stream } = streams[0]! - stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push(anchor()) await tick() // Poison wakes the consumer, but the synchronous stop lands first, so the // failure arrives on an already-stopped entry and stays silent. @@ -370,7 +497,7 @@ describe('ClientJobOutput observation streams', () => { await tick() expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() - const second = output.observe(undefined, ID) + const second = jobs.observe(undefined, ID) await ctx.fiber.dispose() // The disposal path already removed every entry; a live releaser whose // entry is gone is inert. @@ -378,11 +505,11 @@ describe('ClientJobOutput observation streams', () => { }) it('stays silent when the carrier teardown itself throws after a stop', async () => { - const { model, output, streams } = bench() - const stop = output.observe(undefined, ID) + const { model, jobs, streams } = bench() + const stop = jobs.observe(undefined, ID) const { stream } = streams[0]! stream.throwOnDispose = true - stream.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push(anchor()) await tick() // The releaser marks the entry stopped, then its dispose surfaces as an // iterator throw — which the consumer swallows for a stopped entry. @@ -392,9 +519,9 @@ describe('ClientJobOutput observation streams', () => { }) it('holds service disposal until every open stream reports quiescence', async () => { - const { ctx, output, streams } = bench() - output.observe(undefined, ID) - output.observe(undefined, 'bash-2' as JobId) + const { ctx, jobs, streams } = bench() + jobs.watchRows(S1) + jobs.observe(undefined, ID) for (const { stream } of streams) stream.deferDispose = true await tick() @@ -416,21 +543,18 @@ describe('ClientJobOutput observation streams', () => { }) it('records a terminal stream failure and service disposal closes live streams', async () => { - const { ctx, model, output, streams } = bench() - output.observe(undefined, ID) + const { ctx, model, jobs, streams } = bench() + jobs.observe(undefined, ID) const failing = streams[0]!.stream - failing.push({ type: 'opened', jobId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + failing.push(anchor()) await tick() - const iterator = failing[Symbol.asyncIterator]() - void iterator // Simulate a terminal consumer failure by disposing the underlying stream: // the consumer loop ends without a status frame and cleans up. const spy = vi.spyOn(model, 'observeFailed') await failing.dispose() await tick() - const other = 'bash-2' as JobId - output.observe(undefined, other) + jobs.observe(undefined, 'bash-2' as JobId) await ctx.fiber.dispose() expect(streams.every(entry => entry.stream.disposed)).toBe(true) expect(spy).not.toHaveBeenCalled() diff --git a/packages/api/job-controller/tests/observe.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts index bc6827fdc2..7f36b8df1e 100644 --- a/packages/api/job-controller/tests/observe.host.spec.ts +++ b/packages/api/job-controller/tests/observe.host.spec.ts @@ -3,123 +3,58 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { JobId } from '@deepseek-ai/dsh-jobs' -import type { JobOutcome, RecordingJob } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { JobController } from '../src/index.ts' -import { observeJobRecord } from '../src/observe.ts' +import { observeJobOutput } from '../src/observe.ts' import type { ObserveJobOptions } from '../src/observe.ts' import type { JobObserveFrame } from '../src/types.ts' +import { collect, harness, next, registerAgent, startJob, wait } from './host-harness.ts' -async function harness() { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry, { retainBytes: 64, settledRetainBytes: 64 }) - ctx.jobs.attachController('observe-job-test') - return ctx -} +const OBSERVE: ObserveJobOptions = { flushMs: 5, maxFrameBytes: 1024 } -function registerAgent(ctx: Context, rawId: string): Agent { - const scopeFiber = ctx.plugin(() => {}) - const id = SessionId(rawId) - const session = Session.create(id) - const agent = { - id, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle', - ctx: scopeFiber.ctx, - } as unknown as Agent - ctx.agents.register(agent) - return agent -} - -/** Start one record job whose done settles on demand; expose the producer face. */ -function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; record?: boolean } = {}) { - let settle!: (outcome: JobOutcome) => void - let face!: RecordingJob - const id = ctx.jobs.start({ - kind: 'bash', - label: options.label ?? 'echo', - ...options.owner !== undefined ? { owner: options.owner } : {}, - ...options.record === false ? {} : { record: true }, - run: (job: RecordingJob) => { - face = job - return { - cancel() {}, - done: new Promise((resolve) => { settle = resolve }), - } - }, - }) - return { - id, - append: (text: string, opts?: Parameters[1]) => { face.append(text, opts) }, - updateDetail: (detail: string) => { face.updateDetail(detail) }, - settle: async (outcome: JobOutcome) => { - settle(outcome) - await new Promise(resolve => setTimeout(resolve, 0)) - }, - } -} - -/** Read frames until `count` arrive or the generator finishes, then abort. */ -async function collect( - iterable: AsyncIterable, - count: number, - abort: AbortController, -): Promise { - const frames: Frame[] = [] - const iterator = iterable[Symbol.asyncIterator]() - try { - while (frames.length < count) { - const step = await iterator.next() - if (step.done) return frames - frames.push(step.value) - } - } finally { - abort.abort() - await iterator.next().catch(() => undefined) - } - return frames -} - -const OBSERVE: Omit = { flushMs: 5, maxFrameBytes: 1024 } - -describe('observeJobRecord', () => { - function observed(ctx: Context, request: { jobId: JobId; from?: number }, caller?: Agent) { +describe('observeJobOutput', () => { + function observed(ctx: Context, request: { jobId: JobId; sessionId?: SessionId; from?: number }) { const abort = new AbortController() const frames: JobObserveFrame[] = [] - const stream = observeJobRecord(ctx.jobs, request, { ...OBSERVE, caller }, abort.signal) + const stream = observeJobOutput(ctx.jobs, request, OBSERVE, abort.signal) const done = (async () => { for await (const frame of stream) frames.push(frame) })() return { abort, frames, done } } - it('anchors, streams coalesced output, and closes with the terminal status', async () => { + it('anchors, streams coalesced output, and closes with the terminal projection', async () => { const ctx = await harness() - const job = startRecordJob(ctx) + const job = startJob(ctx) job.append('early ') const { frames, done } = observed(ctx, { jobId: job.id }) - await new Promise(resolve => setTimeout(resolve, 20)) + await wait(20) job.append('live', { channel: 'stderr' }) - await new Promise(resolve => setTimeout(resolve, 20)) + await wait(20) await job.settle({ status: 'completed', detail: 'exit code: 0' }) await done - expect(frames[0]).toMatchObject({ type: 'opened', from: 0, earliest: 0, total: 6, status: 'running' }) + expect(frames[0]).toMatchObject({ + type: 'opened', + from: 0, + job: { id: job.id, status: 'running', output: { earliest: 0, total: 6 } }, + }) const outputs = frames.filter(frame => frame.type === 'output') const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') expect(text).toBe('early live') expect(outputs.at(-1)?.next).toBe(10) expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr') - expect(frames.at(-1)).toMatchObject({ type: 'status', status: 'completed', detail: 'exit code: 0' }) + expect(frames.at(-1)).toMatchObject({ + type: 'status', + job: { status: 'completed', detail: 'exit code: 0', output: { total: 10 } }, + }) }) it('resumes from an explicit offset and flags an evicted resume lossy', async () => { const ctx = await harness() - const job = startRecordJob(ctx, { label: 'spam' }) + const job = startJob(ctx, { label: 'spam' }) job.append('a'.repeat(60)) job.append('b'.repeat(60)) await job.settle({ status: 'completed' }) @@ -138,17 +73,13 @@ describe('observeJobRecord', () => { it('splits oversized reads along the frame budget', async () => { const ctx = await harness() - const job = startRecordJob(ctx, { label: 'wide' }) + const job = startJob(ctx, { label: 'wide' }) job.append('x'.repeat(30)) job.append('y'.repeat(30)) await job.settle({ status: 'completed' }) const abort = new AbortController() const frames = await collect( - observeJobRecord(ctx.jobs, { jobId: job.id }, { - flushMs: 5, - maxFrameBytes: 30, - caller: undefined, - }, abort.signal), + observeJobOutput(ctx.jobs, { jobId: job.id }, { flushMs: 5, maxFrameBytes: 30 }, abort.signal), 5, abort, ) @@ -160,18 +91,14 @@ describe('observeJobRecord', () => { it('carries the lossy flag on the first frame of a split evicted read', async () => { const ctx = await harness() - const job = startRecordJob(ctx, { label: 'evicted split' }) + const job = startJob(ctx, { label: 'evicted split' }) job.append('a'.repeat(40)) job.append('b'.repeat(40)) job.append('c'.repeat(40)) await job.settle({ status: 'completed' }) const abort = new AbortController() const frames = await collect( - observeJobRecord(ctx.jobs, { jobId: job.id, from: 0 }, { - flushMs: 5, - maxFrameBytes: 40, - caller: undefined, - }, abort.signal), + observeJobOutput(ctx.jobs, { jobId: job.id, from: 0 }, { flushMs: 5, maxFrameBytes: 40 }, abort.signal), 5, abort, ) @@ -180,61 +107,49 @@ describe('observeJobRecord', () => { expect(outputs[1]?.lossy).toBeUndefined() }) - it('reads owned records through the resolved caller and rejects foreign, bad, and unknown input', async () => { + it('reads owned output through the request session and rejects foreign, bad, and unknown input', async () => { const ctx = await harness() const owner = registerAgent(ctx, 'alice') - const job = startRecordJob(ctx, { label: 'secret', owner }) + const job = startJob(ctx, { label: 'secret', owner: owner.id }) job.append('classified') await job.settle({ status: 'completed' }) - const { frames, done } = observed(ctx, { jobId: job.id }, owner) + const { frames, done } = observed(ctx, { jobId: job.id, sessionId: owner.id }) await done expect(frames.some(frame => frame.type === 'output' && frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true) const foreign = new AbortController() await expect(collect( - observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, foreign.signal), + observeJobOutput(ctx.jobs, { jobId: job.id }, OBSERVE, foreign.signal), 1, foreign, )).rejects.toThrow(/belongs to another session/) const abort = new AbortController() await expect(collect( - observeJobRecord(ctx.jobs, { jobId: job.id, from: -1 }, { ...OBSERVE, caller: owner }, abort.signal), + observeJobOutput(ctx.jobs, { jobId: job.id, sessionId: owner.id, from: -1 }, OBSERVE, abort.signal), 1, abort, )).rejects.toThrow(/invalid observe offset/) const abortUnknown = new AbortController() await expect(collect( - observeJobRecord(ctx.jobs, { jobId: JobId('bash-999') }, { ...OBSERVE, caller: undefined }, abortUnknown.signal), + observeJobOutput(ctx.jobs, { jobId: JobId('bash-999') }, OBSERVE, abortUnknown.signal), 1, abortUnknown, )).rejects.toThrow(/unknown job/) }) - it('refuses a job that declared no record', async () => { + it('anchors with the live progress and takes the fast wait path for a wake that lands mid-yield', async () => { const ctx = await harness() - const job = startRecordJob(ctx, { record: false }) + const job = startJob(ctx) + job.progress('3/10') const abort = new AbortController() - await expect(collect( - observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, abort.signal), - 1, - abort, - )).rejects.toThrow(/declared no output record/) - await job.settle({ status: 'completed' }) - }) - - it('anchors with the live detail and takes the fast wait path for a wake that lands mid-yield', async () => { - const ctx = await harness() - const job = startRecordJob(ctx) - job.updateDetail('3/10') - const abort = new AbortController() - const iterator = observeJobRecord(ctx.jobs, { jobId: job.id }, { ...OBSERVE, caller: undefined }, abort.signal)[Symbol.asyncIterator]() + const iterator = observeJobOutput(ctx.jobs, { jobId: job.id }, OBSERVE, abort.signal)[Symbol.asyncIterator]() const anchor = await iterator.next() - expect(anchor.value).toMatchObject({ type: 'opened', detail: '3/10' }) + expect(anchor.value).toMatchObject({ type: 'opened', job: { progress: '3/10' } }) // Both chunks land while the generator is suspended on a yield, so each // wake precedes the loop's wait(): draining the first chunk must pass // through the already-woken fast path to read the second. @@ -247,18 +162,18 @@ describe('observeJobRecord', () => { const pending = iterator.next() await job.settle({ status: 'completed' }) const status = await pending - expect(status.value).toMatchObject({ type: 'status', status: 'completed' }) + expect(status.value).toMatchObject({ type: 'status', job: { status: 'completed' } }) expect((await iterator.next()).done).toBe(true) }) it('ignores output signals for other jobs while waiting', async () => { const ctx = await harness() - const watched = startRecordJob(ctx, { label: 'watched' }) - const noisy = startRecordJob(ctx, { label: 'noisy' }) + const watched = startJob(ctx, { label: 'watched' }) + const noisy = startJob(ctx, { label: 'noisy' }) const { abort, frames, done } = observed(ctx, { jobId: watched.id }) - await new Promise(resolve => setTimeout(resolve, 15)) + await wait(15) noisy.append('unrelated') - await new Promise(resolve => setTimeout(resolve, 15)) + await wait(15) await watched.settle({ status: 'completed' }) await done abort.abort() @@ -268,9 +183,9 @@ describe('observeJobRecord', () => { it('stops cleanly on abort while waiting for output', async () => { const ctx = await harness() - const job = startRecordJob(ctx, { label: 'idle' }) + const job = startJob(ctx, { label: 'idle' }) const { abort, frames, done } = observed(ctx, { jobId: job.id }) - await new Promise(resolve => setTimeout(resolve, 20)) + await wait(20) abort.abort() await done expect(frames[0]?.type).toBe('opened') @@ -279,18 +194,31 @@ describe('observeJobRecord', () => { }) }) -describe('JobController.observe', () => { +describe('JobController', () => { async function controllerHarness() { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(TypertRegistry) - ctx.jobs.attachController('observe-job-test') + ctx.jobs.attachController('job-controller-test') await ctx.plugin(JobController, {}) return ctx } + function liveOwner(ctx: Context, rawId: string): Agent { + const session = ctx.sessions.create(SessionId(rawId)) + const owner = { + id: session.id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx, + } as unknown as Agent + ctx.agents.register(owner) + return owner + } + it('waits for the job registry instead of serving without one', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -301,24 +229,16 @@ describe('JobController.observe', () => { expect(ctx.get('jobController')).toBeUndefined() }) - it('resolves the fenced-read caller from the request session', async () => { + it('observe reads an owned job through the request session', async () => { const ctx = await controllerHarness() - const session = ctx.sessions.create(SessionId('observing-session')) - const owner = { - id: session.id, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle', - ctx, - } as unknown as Agent - ctx.agents.register(owner) - const job = startRecordJob(ctx, { label: 'owned run', owner }) + const owner = liveOwner(ctx, 'observing-session') + const job = startJob(ctx, { label: 'owned run', owner: owner.id }) job.append('hi') await job.settle({ status: 'completed' }) const abort = new AbortController() const frames = await collect( - ctx.jobController.observe({ sessionId: session.id, jobId: job.id }, abort.signal), + ctx.jobController.observe({ sessionId: owner.id, jobId: job.id }, abort.signal), 3, abort, ) @@ -327,7 +247,7 @@ describe('JobController.observe', () => { it('observes an unowned job without a request session', async () => { const ctx = await controllerHarness() - const job = startRecordJob(ctx, { label: 'unowned run' }) + const job = startJob(ctx, { label: 'unowned run' }) job.append('open access') await job.settle({ status: 'completed' }) @@ -339,4 +259,22 @@ describe('JobController.observe', () => { ) expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) }) + + it('rows mirrors the session-visible set and follows a settlement', async () => { + const ctx = await controllerHarness() + const owner = liveOwner(ctx, 'listing-session') + const owned = startJob(ctx, { label: 'owned', owner: owner.id }) + startJob(ctx, { label: 'shared' }) + + const abort = new AbortController() + const iterator = ctx.jobController.rows({ sessionId: owner.id }, abort.signal)[Symbol.asyncIterator]() + const first = await next(iterator) + expect(first?.jobs.map(job => job.label)).toEqual(['owned', 'shared']) + const pending = next(iterator) + await owned.settle({ status: 'completed', detail: 'exit code: 0' }) + const second = await pending + expect(second?.jobs.find(job => job.id === owned.id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) + abort.abort() + expect(await next(iterator)).toBeUndefined() + }) }) diff --git a/packages/api/job-controller/tests/rows.host.spec.ts b/packages/api/job-controller/tests/rows.host.spec.ts new file mode 100644 index 0000000000..f246425605 --- /dev/null +++ b/packages/api/job-controller/tests/rows.host.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { streamJobRows } from '../src/rows.ts' +import { harness, next, registerAgent, startJob, wait } from './host-harness.ts' + +const ROWS = { flushMs: 5 } + +describe('streamJobRows', () => { + it('opens with the visible set and re-sends it after each coalesced lifecycle burst', async () => { + const ctx = await harness() + const alice = registerAgent(ctx, 'alice') + const bob = registerAgent(ctx, 'bob') + startJob(ctx, { label: 'shared' }) + const mine = startJob(ctx, { label: 'mine', owner: alice.id }) + startJob(ctx, { label: 'theirs', owner: bob.id }) + + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: alice.id }, ROWS, abort.signal)[Symbol.asyncIterator]() + const first = await next(iterator) + expect(first?.jobs.map(job => job.label)).toEqual(['shared', 'mine']) + + // Another owner's lifecycle and this job's own output never wake the roster; + // the next frame is the one the settlement earns, and it carries the final + // byte count without a frame per append. + startJob(ctx, { label: 'theirs too', owner: bob.id }) + mine.append('progress bytes') + await wait(20) + const pending = next(iterator) + await mine.settle({ status: 'completed', detail: 'exit code: 0' }) + const second = await pending + expect(second?.jobs.map(job => job.label)).toEqual(['shared', 'mine']) + expect(second?.jobs[1]).toMatchObject({ status: 'completed', detail: 'exit code: 0', output: { total: 14 } }) + abort.abort() + expect(await next(iterator)).toBeUndefined() + }) + + it('follows progress, stopping, and registration commits', async () => { + const ctx = await harness() + const alice = registerAgent(ctx, 'alice') + const job = startJob(ctx, { label: 'first', owner: alice.id, slowStop: true }) + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: alice.id }, ROWS, abort.signal)[Symbol.asyncIterator]() + await next(iterator) + + job.progress('3/10') + expect((await next(iterator))?.jobs[0]).toMatchObject({ progress: '3/10' }) + ctx.jobs.visibleTo(alice.id).kill(job.id, { reason: 'enough' }) + expect((await next(iterator))?.jobs[0]).toMatchObject({ status: 'stopping', progress: '3/10' }) + startJob(ctx, { label: 'second', owner: alice.id }) + expect((await next(iterator))?.jobs.map(entry => entry.label)).toEqual(['first', 'second']) + abort.abort() + expect(await next(iterator)).toBeUndefined() + await job.settle({ status: 'killed' }) + }) + + it('drops an owner\'s rows when its lifecycle ends, keeping the unowned set', async () => { + const ctx = await harness() + const alice = registerAgent(ctx, 'alice') + startJob(ctx, { label: 'shared' }) + startJob(ctx, { label: 'mine', owner: alice.id }) + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: alice.id }, ROWS, abort.signal)[Symbol.asyncIterator]() + expect((await next(iterator))?.jobs).toHaveLength(2) + + await alice.disposeScope() + // Teardown cancels, settles, and removes the owned job; the frames land in + // that order, and the last one holds the unowned job alone. + let frame = await next(iterator) + while (frame !== undefined && frame.jobs.length !== 1) frame = await next(iterator) + expect(frame?.jobs.map(job => job.label)).toEqual(['shared']) + abort.abort() + expect(await next(iterator)).toBeUndefined() + }) + + it('stops cleanly on abort while idle and refuses an already-aborted signal', async () => { + const ctx = await harness() + startJob(ctx, { label: 'idle' }) + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: 'nobody' as never }, ROWS, abort.signal)[Symbol.asyncIterator]() + expect((await next(iterator))?.jobs.map(job => job.label)).toEqual(['idle']) + await wait(10) + abort.abort() + expect(await next(iterator)).toBeUndefined() + + const aborted = new AbortController() + aborted.abort() + const refused = streamJobRows(ctx.jobs, { sessionId: 'nobody' as never }, ROWS, aborted.signal)[Symbol.asyncIterator]() + await expect(refused.next()).rejects.toThrow() + }) +}) diff --git a/packages/api/job-controller/tsconfig.host.json b/packages/api/job-controller/tsconfig.host.json index 96de4dede2..4e5c8c3f01 100644 --- a/packages/api/job-controller/tsconfig.host.json +++ b/packages/api/job-controller/tsconfig.host.json @@ -8,12 +8,13 @@ "files": [ "src/index.ts", "src/observe.ts", - "src/types.ts" + "src/rows.ts", + "src/types.ts", + "src/wake.ts" ], "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, - { "path": "../../core/agent" }, { "path": "../../core/session" }, { "path": "../../jobs/jobs" }, { "path": "../../typert/protocol" } From 84efac2c4297b84b00768eca3973787ed7146d75 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 17:00:51 +0800 Subject: [PATCH 036/141] refactor(ui-jobs): read rows and observations from ctx.jobs The header list watches its session's roster through ctx.jobs.watchRows while mounted and reads rows and observed views from one jobs snapshot. A row is expandable while the job is live or once it left retained output behind; it shows the live progress line or the terminal detail. The web e2e drivers kill through visibleTo(), and the client fixtures the roster removal missed drop their jobs baselines. --- apps/web/tests/background-job-list.e2e.ts | 2 +- apps/web/tests/live-job-stream.e2e.ts | 8 +- .../tests/client-apply.client.spec.ts | 2 +- .../tests/fake-api.client.ts | 1 - .../tests/projection-store.client.spec.ts | 2 +- .../tests/queue-store.client.spec.ts | 1 - .../tests/sessions-service.client.spec.ts | 2 +- packages/client/ui-jobs/README.i18n.yaml | 4 +- packages/client/ui-jobs/README.md | 19 ++- packages/client/ui-jobs/README.zh.md | 19 ++- packages/client/ui-jobs/package.json | 2 - .../ui-jobs/src/client/JobListAction.tsx | 81 +++++++----- packages/client/ui-jobs/src/client/index.ts | 17 ++- .../tests/browser-plugin.client.spec.ts | 25 ++-- .../tests/job-list-action.client.spec.tsx | 125 +++++++++++------- 15 files changed, 178 insertions(+), 132 deletions(-) diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 96a7f4f833..5d881f59b4 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -111,7 +111,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { expect(tripwire.warnings).toEqual([]) phase = 'settled' - expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') + expect(scaffold.ctx.jobs.visibleTo(agent.id).kill(jobId, { reason: 'web e2e cancellation' })).toBe('requested') const idle = page.getByRole('button', { name: '1 background job', exact: true }) await idle.waitFor({ timeout: 20_000 }) diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts index 83736763c6..ccddae0ee2 100644 --- a/apps/web/tests/live-job-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -1,5 +1,5 @@ // Session-header job list driven by a real background bash run: the job row -// arrives over the session control stream, expanding it opens the record +// arrives over the job roster stream, expanding it opens the output // observation stream, and the panel shows the process's real output while it // is still running. No model call is involved. import { readFile, writeFile } from 'node:fs/promises' @@ -99,11 +99,11 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) const jobId = JobId(matched[0]) - // The job row reaches the header over the session control stream. + // The job row reaches the header over the job roster stream. await trigger.waitFor({ timeout: 15_000 }) await trigger.click() - // Expanding the row opens the record observation stream; the panel then renders + // Expanding the row opens the output observation stream; the panel then renders // the command's real stdout while the process is still running. const expand = page.getByRole('button', { name: /Show live output/ }) await expand.waitFor({ timeout: 10_000 }) @@ -116,7 +116,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { expect(tripwire.warnings).toEqual([]) phase = 'settled' - expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') + expect(scaffold.ctx.jobs.visibleTo(agent.id).kill(jobId, { reason: 'web e2e cancellation' })).toBe('requested') // Settlement arrives on the observation stream itself, so the open panel // flips without any further interaction; the roster trigger follows. diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 07f88b1a4f..29b435e3f9 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -150,7 +150,7 @@ describe('Session Controller Client apply', () => { expect(accept).toHaveBeenCalledWith({ type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { queues: {}, projections: {} }, }) bench.api.failStreams(new RemoteStreamCarrierError('generation lost')) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index e3ea48f783..14db19d4ce 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -155,7 +155,6 @@ export class FakeApiClient { followCursor: number | undefined controlBaseline: SessionControlBaseline = { queues: {}, - jobs: {}, projections: {}, } workspaceBaseline: Extract['value'] = { diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 74f37c57cd..2dc1b98998 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -172,7 +172,7 @@ describe('manager frame routing', () => { manager.handleControlFrame({ type: 'baseline', value: { - queues: {}, jobs: {}, + queues: {}, projections: { [sid('s1')]: { asOfSeq: 2, values: {} } }, }, }) diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts index 722a06b090..fd0f261c21 100644 --- a/packages/api/session-controller/tests/queue-store.client.spec.ts +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -279,7 +279,6 @@ describe('manager buffering of queue snapshots', () => { type: 'baseline', value: { queues: { [SID]: nextQueue }, - jobs: {}, projections: {}, }, }) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index c98ff399fb..5d3d040612 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -139,7 +139,7 @@ describe('scope tree', () => { b.svc.handleControlFrame({ type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, + value: { queues: {}, projections: {} }, }) await Promise.resolve() expect(b.svc.resolveAgentScope(sid('s-early'))).toBe(scoped) diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index 7a5e54a34f..acc54648f7 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-jobs/README.md -README.md: fe357b5555cdc2f7a94ea0a44e97c499652695d3 -README.zh.md: d23b96becc5b2598197f7720a355a37e5c8bf7cb +README.md: 697bb2dc488e11d3c92c061cc83fabeebeb366dc +README.zh.md: 3b05b5796fe366ce6e2f4cb389b758e8855483b7 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index fe357b5555..697bb2dc48 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -1,5 +1,5 @@ --- -description: "The session-header background-job list: expandable streaming record panels, running/finished sections, and static rows for jobs without a record." +description: "The session-header background-job list: expandable streaming output panels, running/finished sections, and static rows for settled jobs without retained output." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-jobs` puts this session's background jobs in one header control. Every row carries the job's lifecycle, ticking duration, and model-visible `detail` from the `jobsBySession` mirror; a job that declared an observation record additionally offers an expandable panel that streams its real output while it runs. Collapsing stops the stream, so output only flows while someone is watching. Live rows lead with a ticking duration over a kind-and-status second line; settled rows compact behind a section heading; jobs without a record (subagent delegations, PTY sends) render as static rows with no expansion affordance. +`dsh-client-ui-jobs` puts this session's background jobs in one header control. Every row carries the job's lifecycle, ticking duration, and the model-visible progress line or terminal detail from the roster `ctx.jobs` keeps current; a live job, or a settled one that left retained output behind, additionally offers an expandable panel that streams its real output. Collapsing stops the stream, so output only flows while someone is watching. Live rows lead with the ticking duration and carry kind and status on a second line; settled rows fold after a section heading; a settled job that retained no output (a subagent, whose answer went to the model) renders as a static row. ## Table of Contents @@ -29,11 +29,11 @@ Load the plugin through the web-app manifest; it renders nothing until the sessi ### One row per job -The session control stream's `jobsBySession` mirror is the single roster: each `SessionJob` row carries lifecycle, duration, and the model-visible `detail`, and its `record` flag is present exactly when the job declared a record — that flag is what makes a row expandable. There is no second roster to join. +The `job.rows` stream `ctx.jobs` mirrors is the single roster: each `JobView` row carries lifecycle, duration, the live `progress` line or the terminal `detail`, and its retained byte count — a live job, or a settled one with retained output, is what makes a row expandable. There is no second roster to join. ### The expanded panel -Expanding an observable row opens that job's record observation stream from `ctx.jobOutput` (installed by `dsh-api-job-controller`) into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel. +Expanding an observable row opens that job's output observation stream from `ctx.jobs` (installed by `dsh-api-job-controller`) into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream interruptions render as notices above the panel. ----- @@ -43,7 +43,7 @@ Expanding an observable row opens that job's record observation stream from `ctx
        Implementation internals — click to expand -One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobOutput` and the standard `useSessions` hook — the component holds no transport state. Observation follows visibility: one `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover dismissal. +One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.rows` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. | File | Role | |---|---| @@ -58,9 +58,8 @@ One slot entry in the header actions band (after the preset label, before the su ## Further Exploration -- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.observe` stream and the `ctx.jobOutput` service behind the panel. -- [`dsh-api-session-controller`](../../api/session-controller/README.md) — the `jobsBySession` roster mirror the rows come from. -- [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the record semantics. +- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.rows` and `job.observe` streams and the `ctx.jobs` service behind the rows and the panel. +- [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the ring and projection semantics. - [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures. ----- @@ -80,7 +79,7 @@ None; the package never assembles or sends provider requests. These limits define current package constraints, not a task backlog. -- **No kill control** — cancellation stays with the model's `job_kill`; a human kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). +- **No kill control** — cancellation stays with the model's `job_kill`. - **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up. @@ -93,4 +92,4 @@ None.
        -**Runtime invariant:** No companion is published. This package is a read-only projection of the `jobsBySession` mirror onto one header slot entry. It emits no cordis events, owns no cross-plugin mutable state, and its single slot registration proves disposal through the HMR-safety spec. +**Runtime invariant:** No companion is published. This package is a read-only projection of the `ctx.jobs` rosters and views onto one header slot entry. It emits no cordis events, owns no cross-plugin mutable state, and its single slot registration proves disposal through the HMR-safety spec. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index d23b96becc..3b05b5796f 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -1,5 +1,5 @@ --- -description: "会话头部后台任务列表:可展开的流式 record 面板、进行中/已结束分组,以及无 record 任务的静态行。" +description: "会话头部后台任务列表:可展开的流式输出面板、进行中/已结束分组,以及无保留输出的已结束任务的静态行。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-jobs` 把本会话的后台任务放进一个头部控件。每一行都携带 `jobsBySession` 镜像里该 job 的生命周期、跳动时长与模型可见的 `detail`;声明了观测 record 的 job 额外提供可展开的面板,在运行期间流式呈现其真实输出。收起即停流——只有有人在看时输出才流动。进行中的行以跳动时长为主行、kind 与状态为副行;已结束的行折叠在分组标题之后;没有 record 的 job(subagent 委托、PTY 发送)渲染为无展开操作的静态行。 +`dsh-client-ui-jobs` 把本会话的后台任务放进一个头部控件。每一行都携带 `ctx.jobs` 保持最新的名册里该 job 的生命周期、跳动时长与模型可见的进度行或终态 detail;进行中的 job,或结算后仍留有保留输出的 job,额外提供可展开的面板,流式呈现其真实输出。收起即停流——只有有人在看时输出才流动。进行中的行以跳动时长为主行、kind 与状态为副行;已结束的行折叠在分组标题之后;没有保留输出的已结束 job(subagent,其回答已交给模型)渲染为静态行。 ## 目录 @@ -29,11 +29,11 @@ kind: "package-reference" ### 一 job 一行 -session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJob` 行携带生命周期、时长与模型可见的 `detail`,其 `record` 标志恰在 job 声明了 record 时存在——这个标志就是行可展开的依据。不存在需要 join 的第二份名册。 +`ctx.jobs` 镜像的 `job.rows` 流是唯一名册:每个 `JobView` 行携带生命周期、时长、实时 `progress` 行或终态 `detail`,以及其保留字节数——进行中的 job,或留有保留输出的已结束 job,就是行可展开的依据。不存在需要 join 的第二份名册。 ### 展开的面板 -展开可观察的行会从 `ctx.jobOutput`(由 `dsh-api-job-controller` 安装)打开该 job 的 record 观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。 +展开可观察的行会从 `ctx.jobs`(由 `dsh-api-job-controller` 安装)打开该 job 的输出观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。 ----- @@ -43,7 +43,7 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo
        实现内幕——点击展开 -头部操作带里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobOutput` 与标准 `useSessions` hook 到达——组件不持有任何传输状态。观测跟随可见性:一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 +头部操作带里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.rows` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 | 文件 | 角色 | |---|---| @@ -58,9 +58,8 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo ## 进一步探索 -- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 面板背后的 `job.observe` 流与 `ctx.jobOutput` 服务。 -- [`dsh-api-session-controller`](../../api/session-controller/README.zh.md) —— 行所来自的 `jobsBySession` 名册镜像。 -- [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有 record 语义的注册表契约。 +- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 行与面板背后的 `job.rows`、`job.observe` 流与 `ctx.jobs` 服务。 +- [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有环与投影语义的注册表契约。 - [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md) —— 面板所配置的 `TerminalBlock` 表面。 ----- @@ -80,7 +79,7 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo 这些限制界定当前包约束,不是任务清单。 -- **没有 kill 控件**——取消仍归模型的 `job_kill`;人工 kill 卡在 [web 任务显示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题上。 +- **没有 kill 控件**——取消仍归模型的 `job_kill`。 - **不渲染 channel 标签**——stdout 与 stderr 块拼接为一条流;按 channel 着色是展示层的后续工作。 @@ -93,4 +92,4 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo
        -**运行时不变式:** 不发布伴生入口。本包只把 `jobsBySession` mirror 只读投影到一个 header slot,不发出 Cordis 事件,也不持有跨插件可变状态。 +**运行时不变式:** 不发布伴生入口。本包只把 `ctx.jobs` 的名册与视图只读投影到一个 header slot,不发出 Cordis 事件,也不持有跨插件可变状态。 diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 27ab997467..92f29f2db1 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -36,7 +36,6 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-job-controller": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -57,7 +56,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-job-controller", - "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives" diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index bf369c9949..c7af7fe160 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,6 +1,5 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' -import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' +import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconChevronDownOutline14, StateDot, TerminalBlock, useDismissOnOutsidePointer, @@ -14,17 +13,22 @@ import css from './JobListAction.module.css' /** Registration-side business face for the job list. */ export interface JobListInjected { hooks: { - /** Client job-output snapshot bound by the renderer as useJobOutput. */ - jobOutput: { - getSnapshot(): JobOutputSnapshot + /** Client jobs snapshot (rosters and observations) bound by the renderer as useJobs. */ + jobs: { + getSnapshot(): JobsSnapshot subscribe(listener: () => void): () => void } } /** - * Start observing one job's live record; returns the stop function. + * Keep one session's roster current while the list is mounted; returns the + * stop function. Reference-counted by the client service. + */ + watchRows: (sessionId: SessionId) => () => void + /** + * Start observing one job's live output; returns the stop function. * Reference-counted by the client service, so panels can overlap safely. */ - observe: (sessionId: SessionId | undefined, id: SessionJob['id']) => () => void + observe: (sessionId: SessionId | undefined, id: JobView['id']) => () => void } /** Full props for the session-header job-list action. */ @@ -34,19 +38,27 @@ export type JobListActionProps = & InjectFace /** Stable empty list so a session with no jobs keeps one array identity. */ -const NO_JOBS: readonly SessionJob[] = [] +const NO_JOBS: readonly JobView[] = [] /** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */ const VIEWPORT_MARGIN = 12 -function isLive(job: SessionJob): boolean { +function isLive(job: JobView): boolean { return job.status === 'running' || job.status === 'stopping' } -/** True exactly when the job declared an observation record. */ -function isObservable(job: SessionJob): boolean { - return job.record === true +/** + * Whether the row offers an output panel: every live job (its output may + * still arrive) and a settled one that left retained output behind. + */ +function isObservable(job: JobView): boolean { + return isLive(job) || job.output.total > 0 +} + +/** The one-line qualifier beside the status: live progress while running, the terminal reason once settled. */ +function jobDetail(job: JobView): string | undefined { + return job.progress ?? job.detail } /** Closed-union exhaustiveness fence for the wire status set. */ @@ -59,7 +71,7 @@ function assertNever(value: never): never { * Status marker semantics. `stopping` and `killed` share the attention color: * both mean the work ended (or is ending) on request rather than on its own. */ -function dotState(status: SessionJob['status']): StateDotState { +function dotState(status: JobView['status']): StateDotState { switch (status) { case 'running': return 'ongoing' case 'stopping': return 'warning' @@ -71,7 +83,7 @@ function dotState(status: SessionJob['status']): StateDotState { } } -function statusLabel(status: SessionJob['status'], t: TranslateNS): string { +function statusLabel(status: JobView['status'], t: TranslateNS): string { switch (status) { case 'running': return t('status.running') case 'stopping': return t('status.stopping') @@ -130,7 +142,7 @@ function terminalLabels(t: TranslateNS): TerminalBlockLabels { * that settled in the same millisecond fall back to start order, so the sort * never depends on the host's map iteration. */ -function ordered(jobs: readonly SessionJob[]): SessionJob[] { +function ordered(jobs: readonly JobView[]): JobView[] { return [...jobs].sort((left, right) => { const liveLeft = isLive(left) if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 @@ -142,7 +154,7 @@ function ordered(jobs: readonly SessionJob[]): SessionJob[] { /** One job row plus, when observable and expanded, its live output panel. */ function JobItem({ job, view, expanded, now, onToggle, t }: { - job: SessionJob + job: JobView view: ObservedJob | undefined expanded: boolean /** Clock sample live rows derive their running duration from. */ @@ -152,6 +164,7 @@ function JobItem({ job, view, expanded, now, onToggle, t }: { }) { const live = isLive(job) const status = statusLabel(job.status, t) + const detail = jobDetail(job) const observable = isObservable(job) const labels = useMemo(() => terminalLabels(t), [t]) const elapsed = live ? now - job.startedAt : (job.finishedAt ?? job.startedAt) - job.startedAt @@ -175,10 +188,11 @@ function JobItem({ job, view, expanded, now, onToggle, t }: {
        {job.kind} - {job.detail ?? status} + {detail ?? status} - {observable ? : null} + {/* A live row is always observable: its output may still arrive. */} + ) : ( @@ -186,7 +200,7 @@ function JobItem({ job, view, expanded, now, onToggle, t }: { {job.kind} {job.label} - {job.detail ?? status} + {detail ?? status} {durationCell} {observable ? : null} @@ -206,7 +220,7 @@ function JobItem({ job, view, expanded, now, onToggle, t }: { ) : ( - + {body} )} @@ -237,18 +251,19 @@ function JobItem({ job, view, expanded, now, onToggle, t }: { } /** - * Session-header entry point for this session's background jobs. It renders - * nothing at all until the session can see at least one job; expanding an - * observable row (a job that declared a record) starts its observation - * stream, and collapsing (or closing the popover) stops it — output only - * flows while someone is watching. - * @param props - runtime slot currency, the session and job-output snapshot - * hooks, the observation control, and the namespace translator. + * Session-header entry point for this session's background jobs. Mounting it + * keeps the session's roster stream open; it renders nothing at all until the + * session can see at least one job. Expanding an observable row (a live job, + * or a settled one with retained output) starts its observation stream, and + * collapsing (or closing the popover) stops it — output only flows while + * someone is watching. + * @param props - runtime slot currency, the jobs snapshot hook, the roster + * and observation controls, and the namespace translator. * @returns the trigger and its popover list, or null when there is nothing to show. */ -export function JobListAction({ sessionId, useSessions, useJobOutput, observe, t }: JobListActionProps) { - const jobs = useSessions(state => state.jobsBySession[sessionId]) ?? NO_JOBS - const observedViews = useJobOutput(state => state.observed) +export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: JobListActionProps) { + const jobs = useJobs(state => state.rows[sessionId]) ?? NO_JOBS + const observedViews = useJobs(state => state.observed) const [open, setOpen] = useState(false) const [expandedKey, setExpandedKey] = useState(undefined) const [now, setNow] = useState(() => Date.now()) @@ -265,6 +280,10 @@ export function JobListAction({ sessionId, useSessions, useJobOutput, observe, t useDismissOnOutsidePointer(rootRef, open, setOpen) + // The roster follows the mounted session: one stream while this control + // lives, released with it. + useEffect(() => watchRows(sessionId), [sessionId, watchRows]) + // The clock only runs while an open list is showing something that moves. useEffect(() => { if (!open || liveRows.length === 0) return @@ -337,7 +356,7 @@ export function JobListAction({ sessionId, useSessions, useJobOutput, observe, t triggerRef.current?.focus() } - const item = (job: SessionJob) => ( + const item = (job: JobView) => ( ({ - hooks: { jobOutput: ctx.jobOutput.state }, - observe: (sessionId, id) => ctx.jobOutput.observe(sessionId, id), + hooks: { jobs: ctx.jobs.state }, + watchRows: sessionId => ctx.jobs.watchRows(sessionId), + observe: (sessionId, id) => ctx.jobs.observe(sessionId, id), }), }, JobListAction), ) diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index 91a9521ca7..2c70dfe40f 100644 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -19,8 +19,10 @@ function headerEntryIds(ctx: Context): (string | undefined)[] { .map(entry => entry.options.id) } -/** Observation requests handed to the stubbed job-output control. */ +/** Observation requests handed to the stubbed jobs service. */ const observed: [string | undefined, string][] = [] +/** Roster watches handed to the stubbed jobs service. */ +const watched: string[] = [] /** Boot the browser half over a real slot tree that declares the header list. */ async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { @@ -32,9 +34,12 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) - ctx.provide('sessions', {} as never) - ctx.provide('jobOutput', { - state: { getSnapshot: () => ({ observed: {} }), subscribe: () => () => {} }, + ctx.provide('jobs', { + state: { getSnapshot: () => ({ rows: {}, observed: {} }), subscribe: () => () => {} }, + watchRows: (sessionId: string) => { + watched.push(sessionId) + return () => {} + }, observe: (sessionId: string | undefined, id: string) => { observed.push([sessionId, id]) return () => {} @@ -57,21 +62,25 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType { it('declares the services it binds', () => { - expect(inject).toEqual(['sessions', 'jobOutput', 'slots', 'locale']) + expect(inject).toEqual(['jobs', 'slots', 'locale']) }) - it('exposes the output source and observation control through the inject face', async () => { + it('exposes the jobs source and the roster and observation controls through the inject face', async () => { const { ctx } = await bench() const entry = ctx.slots .entries('conversation.session.header.actions') .find(candidate => candidate.options.id === 'job-list') const face = (entry as unknown as { inject: () => { - hooks: { jobOutput: unknown } + hooks: { jobs: unknown } + watchRows: (sessionId: string) => () => void observe: (sessionId: string | undefined, id: string) => () => void } }).inject() - expect(face.hooks.jobOutput).toBeDefined() + expect(face.hooks.jobs).toBeDefined() + const release = face.watchRows('session') + expect(watched).toEqual(['session']) + release() const stopper = face.observe('session', 'bash-1') expect(observed).toEqual([['session', 'bash-1']]) stopper() diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index 9a8b9a1edc..a16a00c76f 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -2,8 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' -import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' +import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' import { zh } from '../src/client/locales.ts' @@ -17,36 +16,35 @@ afterEach(() => { const SESSION = 'session' as SessionId const t: JobListActionProps['t'] = makeTranslate(zh) -function job(over: Partial = {}): SessionJob { +/** A running job with nothing retained yet. */ +function job(over: Partial = {}): JobView { return { - id: 'bash-1' as SessionJob['id'], + id: 'bash-1' as JobView['id'], kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 1_700_000_000_000, + output: { total: 0, earliest: 0 }, ...over, } } -/** A job that declared an observation record, so its row offers a panel. */ -function recordJob(over: Partial = {}): SessionJob { - return job({ record: true, ...over }) +/** A job that left retained output behind, so its row offers a panel even once settled. */ +function outputJob(over: Partial = {}): JobView { + return job({ output: { total: 12, earliest: 0 }, ...over }) } function props( - jobs: readonly SessionJob[], + jobs: readonly JobView[], observe: JobListActionProps['observe'] = () => () => {}, observed: Readonly> = {}, + watchRows: JobListActionProps['watchRows'] = () => () => {}, ): JobListActionProps { - const outputState: JobOutputSnapshot = { observed } - function useJobOutput(select: (value: JobOutputSnapshot) => T): T { - return select(outputState) + const jobsState: JobsSnapshot = { rows: jobs.length > 0 ? { [SESSION]: jobs } : {}, observed } + function useJobs(select: (value: JobsSnapshot) => T): T { + return select(jobsState) } - const sessionsState = { jobsBySession: jobs.length > 0 ? { [SESSION]: jobs } : {} } - function useSessions(select: (value: typeof sessionsState) => T): T { - return select(sessionsState) - } - return { sessionId: SESSION, useSessions, useJobOutput, observe, t } as unknown as JobListActionProps + return { sessionId: SESSION, useJobs, watchRows, observe, t } as unknown as JobListActionProps } function openList(): void { @@ -59,10 +57,20 @@ describe('JobListAction visibility', () => { expect(container.innerHTML).toBe('') }) + it('watches the session roster while mounted and releases it on unmount', () => { + const stop = vi.fn() + const watchRows = vi.fn(() => stop) + const { unmount } = render() + expect(watchRows).toHaveBeenCalledWith(SESSION) + expect(stop).not.toHaveBeenCalled() + unmount() + expect(stop).toHaveBeenCalledTimes(1) + }) + it('counts live jobs on the trigger', () => { render() expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() }) @@ -76,18 +84,24 @@ describe('JobListAction visibility', () => { }) describe('JobListAction rows', () => { - it('renders a record-less job row without expansion affordances', () => { + it('renders a settled job without retained output as a static row', () => { render() - expect(screen.getByRole('button', { name: '1 个后台任务运行中' })).toBeDefined() + expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() openList() expect(screen.getByText('explore the repo')).toBeDefined() expect(screen.queryByRole('button', { name: zh['row.expandAria'].replace('{label}', 'explore the repo') })).toBeNull() }) - it('shows the live detail line in place of the status word', () => { - render() + it('offers a panel on a live row before any output arrived', () => { + render() + openList() + expect(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'warming up') })).toBeDefined() + }) + + it('shows the live progress line in place of the status word', () => { + render() openList() const list = screen.getByRole('list', { name: zh['list.aria'] }) expect(within(list).getAllByRole('listitem')).toHaveLength(1) @@ -96,7 +110,7 @@ describe('JobListAction rows', () => { }) it('labels each non-empty section and drops the heading of an empty one', () => { - const settled = [job({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 1_700_000_012_000 })] + const settled = [job({ id: 'bash-2' as JobView['id'], status: 'completed', finishedAt: 1_700_000_012_000 })] const { rerender } = render() openList() expect(screen.getByText(zh['section.live'])).toBeDefined() @@ -111,9 +125,9 @@ describe('JobListAction rows', () => { vi.useFakeTimers({ now: 1_700_000_020_000 }) render() openList() expect(screen.getByText('5秒')).toBeDefined() @@ -124,10 +138,10 @@ describe('JobListAction rows', () => { expect(screen.getByText('6秒')).toBeDefined() }) - it('lists rows with kind, label, and detail-or-status', () => { + it('lists rows with kind, label, and progress, terminal detail, or status', () => { render() openList() const list = screen.getByRole('list', { name: zh['list.aria'] }) @@ -139,9 +153,9 @@ describe('JobListAction rows', () => { }) it('orders live rows first by start and settled rows newest-first with tie-breaks', () => { - const settled = (id: string, label: string, startedAt: number, finishedAt?: number): SessionJob => + const settled = (id: string, label: string, startedAt: number, finishedAt?: number): JobView => job({ - id: id as SessionJob['id'], + id: id as JobView['id'], label, status: 'completed', startedAt, @@ -153,8 +167,8 @@ describe('JobListAction rows', () => { settled('bash-b', 'settled-b', 90), settled('bash-e', 'settled-e', 95, 50), settled('bash-d', 'settled-d', 10, 200), - job({ id: 'bash-l1' as SessionJob['id'], label: 'live-1', startedAt: 5 }), - job({ id: 'bash-l2' as SessionJob['id'], label: 'live-2', startedAt: 3 }), + job({ id: 'bash-l1' as JobView['id'], label: 'live-1', startedAt: 5 }), + job({ id: 'bash-l2' as JobView['id'], label: 'live-2', startedAt: 3 }), ])} />) openList() const labels = within(screen.getByRole('list', { name: zh['list.aria'] })) @@ -173,14 +187,13 @@ describe('JobListAction observation', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n') const view: ObservedJob = { - jobId: 'bash-1' as SessionJob['id'], + jobId: 'bash-1' as JobView['id'], text: lines, gapBefore: false, - status: 'killed', streaming: false, } render() @@ -204,7 +217,7 @@ describe('JobListAction observation', () => { const originalWidth = window.innerWidth Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) try { - render() + render() openList() const menu = screen.getByRole('list', { name: zh['list.aria'] }) // 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin. @@ -225,7 +238,7 @@ describe('JobListAction observation', () => { const originalWidth = window.innerWidth Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) try { - render() + render() openList() const menu = screen.getByRole('list', { name: zh['list.aria'] }) // max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin. @@ -237,8 +250,8 @@ describe('JobListAction observation', () => { it('ignores other keys while open and closes once the roster empties', () => { const settledPair = [ - recordJob({ id: 'bash-1' as SessionJob['id'], status: 'completed', finishedAt: 2 }), - recordJob({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 3 }), + outputJob({ id: 'bash-1' as JobView['id'], status: 'completed', finishedAt: 2 }), + outputJob({ id: 'bash-2' as JobView['id'], status: 'completed', finishedAt: 3 }), ] const { rerender } = render() expect(screen.getByRole('button', { name: '2 个后台任务' })).toBeDefined() @@ -255,13 +268,12 @@ describe('JobListAction observation', () => { const stop = vi.fn() const observe = vi.fn(() => stop) const view: ObservedJob = { - jobId: 'bash-1' as SessionJob['id'], + jobId: 'bash-1' as JobView['id'], text: 'compiling…\n', gapBefore: false, - status: 'running', streaming: true, } - render() + render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) expect(observe).toHaveBeenCalledWith(SESSION, 'bash-1') @@ -271,16 +283,29 @@ describe('JobListAction observation', () => { expect(stop).toHaveBeenCalledTimes(1) }) + it('renders a running panel with the prompt alone for a live row whose output has not arrived', () => { + const view: ObservedJob = { + jobId: 'bash-1' as JobView['id'], + text: '', + gapBefore: false, + streaming: true, + } + const { container } = render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(container.querySelector('[data-running]')).not.toBeNull() + expect(screen.queryByText(zh['terminal.noOutput'])).toBeNull() + }) + it('surfaces retention gaps and stream failures above the panel', () => { const view: ObservedJob = { - jobId: 'bash-1' as SessionJob['id'], + jobId: 'bash-1' as JobView['id'], text: 'tail only', gapBefore: true, - status: 'running', streaming: false, error: 'connection lost', } - render() + render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) expect(screen.getByText(zh['output.gap'])).toBeDefined() @@ -289,7 +314,7 @@ describe('JobListAction observation', () => { it('stops observation when the popover closes via Escape', () => { const stop = vi.fn() - render( stop)} />) + render( stop)} />) openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) fireEvent.keyDown(screen.getByRole('list', { name: zh['list.aria'] }), { key: 'Escape' }) @@ -301,10 +326,10 @@ describe('JobListAction observation', () => { const stop = vi.fn() // One stable observe identity across renders, as the inject face provides. const observe = () => stop - const { rerender } = render() + const { rerender } = render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - rerender() + rerender() expect(stop).toHaveBeenCalledTimes(1) expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull() }) From cbb0ec0296a0b6597aef81b5e8de8a846d4e144a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 17:15:07 +0800 Subject: [PATCH 037/141] docs(jobs): describe the consolidated seam - docs/subsystems/jobs.md (+zh) records JobSpec, JobHandle, JobHooks, JobOutcome, the pull-source types, JobChunk, JobOutputRead, JobView, JobRead, VisibleJobs, and the event types as type-equivalence blocks; the manifest and the cordis catalog link map follow - the jobs, jobs-local, and tool-jobs READMEs (+zh) describe the ring, the caller-bound view, the event stream, and the notice ledger; the architecture table and the adding-a-tool cookbook drop record: true and readOutput - Agent Note 2026-09-03-jobs-seam-consolidation owns the decision; the 2026-09-01 record note and the 2026-08-08 web display note carry Superseded pointers and the display note links the rows spec - regenerated catalogs (cordis, api, inspect, client, tool, config, persistence, doc graphs, module graph) with their Chinese mirrors; the cordis catalog drops the jobOutput exemption and the tool catalog mounts the agent registry for tool-jobs; the client model imports JobView from its leaf so the typert analyzer resolves it --- ...9-01-jobs-absorb-activity-record.i18n.yaml | 4 +- .../2026-09-01-jobs-absorb-activity-record.md | 2 + ...26-09-01-jobs-absorb-activity-record.zh.md | 2 + ...26-09-03-jobs-seam-consolidation.i18n.yaml | 6 + .../2026-09-03-jobs-seam-consolidation.md | 33 ++ .../2026-09-03-jobs-seam-consolidation.zh.md | 33 ++ ...08-08-web-background-job-display.i18n.yaml | 4 +- .../2026-08-08-web-background-job-display.md | 4 +- ...026-08-08-web-background-job-display.zh.md | 4 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 28 +- docs/config-catalog.zh.md | 28 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 2 +- docs/cookbook/adding-a-tool.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 +- docs/event-producer-consumer.zh.md | 10 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/subsystems/jobs.i18n.yaml | 4 +- docs/subsystems/jobs.md | 552 +++++++++--------- docs/subsystems/jobs.zh.md | 552 +++++++++--------- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 2 +- docs/tool-catalog.zh.md | 2 +- .../api/job-controller/src/client/model.ts | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 179 +++--- packages/jobs/jobs-local/README.i18n.yaml | 4 +- packages/jobs/jobs-local/README.md | 28 +- packages/jobs/jobs-local/README.zh.md | 28 +- packages/jobs/jobs-local/src/events.ts | 2 + packages/jobs/jobs/README.i18n.yaml | 4 +- packages/jobs/jobs/README.md | 25 +- packages/jobs/jobs/README.zh.md | 25 +- packages/jobs/tool-jobs/README.i18n.yaml | 4 +- packages/jobs/tool-jobs/README.md | 17 +- packages/jobs/tool-jobs/README.zh.md | 17 +- pnpm-lock.yaml | 3 - scripts/gen-cordis-catalog.ts | 31 +- scripts/gen-tool-catalog.ts | 3 +- scripts/type-equiv.manifest.json | 50 +- 49 files changed, 929 insertions(+), 820 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md create mode 100644 .agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml index e895f7c65b..491a988b1f 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md -2026-09-01-jobs-absorb-activity-record.md: 2f2bf7a65b5c26c80ec7ddc307c0f31a87f4de54 -2026-09-01-jobs-absorb-activity-record.zh.md: ed62a04e8f9da9de35bd721cceee3a2e56644cdb +2026-09-01-jobs-absorb-activity-record.md: a1da1ba6f47dd0f5d557542d6b9668d6723fd683 +2026-09-01-jobs-absorb-activity-record.zh.md: fd735f1d9764dc83c0469209e241d47176584f5a diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md index 2f2bf7a65b..a1da1ba6f4 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -2,6 +2,8 @@ Status: implemented +Superseded: the record declaration (`JobStart.record`, `RecordingJob`, `readRecord`, `pumpJobOutput`) and the split wire (`SessionJob.record` on the session control stream, `ctx.jobOutput`) described below were consolidated into one output ring, `VisibleJobs`, and `ctx.jobs` — see [the jobs seam consolidation](2026-09-03-jobs-seam-consolidation.md). The argument for absorbing the standalone activity seam into `ctx.jobs` still holds. + English | [中文](2026-09-01-jobs-absorb-activity-record.zh.md) ## Problem diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md index ed62a04e8f..fd735f1d97 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -2,6 +2,8 @@ Status: implemented +Superseded:下文描述的 record 声明(`JobStart.record`、`RecordingJob`、`readRecord`、`pumpJobOutput`)与拆分的线路(会话控制流上的 `SessionJob.record`、`ctx.jobOutput`)已收敛为一个输出环、`VisibleJobs` 与 `ctx.jobs`——见 [jobs seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md)。把独立 activity seam 吸收进 `ctx.jobs` 的论证仍然成立。 + [English](2026-09-01-jobs-absorb-activity-record.md) | 中文 ## 问题 diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml new file mode 100644 index 0000000000..c8e9b6a942 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md +2026-09-03-jobs-seam-consolidation.md: cae6463cee7a2e2a07f66baf759247aaef2afef9 +2026-09-03-jobs-seam-consolidation.zh.md: e87cf61d32e49f74b33e53bf246ccbc64b403baa diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md new file mode 100644 index 0000000000..cae6463cee --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md @@ -0,0 +1,33 @@ +# Agent Note: One output ring, one projection, one event stream for background jobs + +Status: implemented + +English | [中文](2026-09-03-jobs-seam-consolidation.zh.md) + +## Problem + +The job seam had grown by accretion. Producers declared their output twice — a consuming `readOutput` hook the model read and an optional `record: true` ring observers read — and the registry kept two vocabularies for one job (`JobSnapshot` for the model, `SessionJob` on the session control stream, `JobWireChunk` on the observation wire). Three listener families (`onJobDone`, `onJobsChanged`, `onOutput`) delivered overlapping facts with different owner filters, and the registry itself tracked `reported`, a bit that only the model-facing tool could interpret. Review of the merged design called the interface layer the weakest part: a reader could not tell which of `read`, `readRecord`, `JobStart`, `RecordingJob`, and `updateDetail` was the contract and which was an accident of history. + +## Decision + +- **One ring per job.** `JobSpec` replaces `JobStart`: a spec names optional pull `output` sources (`JobOutputSource`, the subprocess `readFrom` family) that the registry pumps at its own cadence (`pumpPollMs` on `dsh-jobs-local`), and every producer receives a `JobHandle` whose `append` pushes chunks into the same ring. The model's consuming read and every observer's absolute-offset read serve the same bytes; `readOutput` and the `record` discriminator are gone. Shell producers hand the registry their `observed` offset readers and fold sandbox facts into the terminal `detail`; the terminal producer adapts its consuming send reader through a byte-counting source; the subagent returns its answer as `JobOutcome.result`, which the first read after settlement carries once. +- **Two cursors, one storage.** `VisibleJobs.read(id)` is the stateful model cursor; `VisibleJobs.readAt(id, from)` is the stateless observer read. Model reads exclude `log` chunks (producer narration for observers) and render stdout, then one `[stderr]` section, exactly as the shell tools always did. +- **`updateProgress` replaces `updateDetail`.** `JobView.progress` is the live line and is cleared at settlement; `JobView.detail` is the terminal reason, with the model's `job_kill` reason merged in. +- **One projection.** `JobView` (client-safe leaf `@deepseek-ai/dsh-jobs/view`) is what the model tools, the browser roster, and the observation frames consume; `owner` is the session id, never the `Agent`. +- **One caller-bound view.** `JobRegistry.visibleTo(caller?)` binds a session once and returns `list`, `get`, `read`, `readAt`, `kill`, and `wait`; every method throws for an id outside the caller's set without distinguishing unknown from foreign. +- **One event stream.** `events.subscribe(filter, listener)` with `{ owner }`, `{ owners: 'scope' }`, or `{ owners: 'all' }` replaces the three listener families. `settled` names its cause (`producer`, `kill`, `teardown`); `output` carries only the id and the new total. +- **The notice ledger moves to `dsh-tool-jobs`.** The registry no longer tracks `reported`. The tool claims a job when a wait starts (and withdraws on timeout or abort) and when `job_kill` is accepted; the settlement event drops the entry, teardown settlements and unowned jobs are skipped, and the destination is the agent registered for the owner session at settlement. +- **The roster leaves the session control stream.** `dsh-api-job-controller` owns `job.rows({ sessionId })` — whole-set frames after each coalesced burst of lifecycle commits, never per append — beside `job.observe`, and its client installs `ctx.jobs` (`watchRows`, `observe`, one snapshot). `dsh-api-session-controller` carries queues and projections only; `ui-jobs` watches its session's roster while mounted, and a row is expandable while live or once it left retained output behind. + +`attachController` and the owner-relative `servesOwner` gate stay as they were; whether a producer may offer `run_in_background` without a mounted controller is deferred, since the gap predates this change. + +## Alternatives considered + +- **Keep three listeners with a shared owner filter.** It preserves the accreted names but not the property reviewers asked for — one place to learn what the registry announces — and `onJobDone`'s exact-owner delivery is what forced the registry to keep an `Agent` in every projection. +- **Keep `reported` in the registry.** Only the tool knows which deliveries reach the model (a wait's tool result, a kill's acknowledgement); the registry could only approximate it, and the approximation was the source of the double-notice and missed-notice bugs the review listed. +- **Keep the roster on the session control stream.** It saved one Remote stream but coupled the session controller to the job registry and mirrored a `record` flag the ring's byte count now answers directly (`output.total`). +- **Serve `log` chunks to the model too.** Producer narration is for observers; the model already receives the same facts through `progress` and the terminal `detail`. + +## Consequences + +The model-visible text is unchanged: status lines, tool descriptions and schemas, and notice wording are byte-identical, and the only new model-visible fact is the kill reason a `killed` status line now carries. A producer that needs to stream output picks pull sources or `append`; it never formats a consuming delta. The Web client renders every live job as expandable, so a job that never writes shows an empty running panel instead of a static row. `JobKindMap` merges are host-side, so a browser program types `JobView.kind` as the kinds it knows and treats the rest as opaque strings, which is all the roster does with it. diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md new file mode 100644 index 0000000000..e87cf61d32 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md @@ -0,0 +1,33 @@ +# Agent Note:后台 job 的一个输出环、一个投影、一条事件流 + +Status: implemented + +[English](2026-09-03-jobs-seam-consolidation.md) | 中文 + +## 问题 + +job seam 是逐步堆出来的。生产者要把输出声明两次——模型读取的消耗式 `readOutput` 钩子,以及观察者读取的可选 `record: true` 环——注册表也为同一个 job 保留了两套词汇(模型侧的 `JobSnapshot`、会话控制流上的 `SessionJob`、观测线路上的 `JobWireChunk`)。三个监听器家族(`onJobDone`、`onJobsChanged`、`onOutput`)用不同的拥有者过滤投递彼此重叠的事实,注册表自己还跟踪着只有模型侧工具才解释得了的 `reported` 位。合并后设计的评审把接口层称作最薄弱的部分:读者分不清 `read`、`readRecord`、`JobStart`、`RecordingJob` 与 `updateDetail` 里哪些是约定、哪些是历史遗留。 + +## 决策 + +- **每个 job 一个环。** `JobSpec` 取代 `JobStart`:spec 声明可选的拉取式 `output` 源(`JobOutputSource`,子进程的 `readFrom` 家族),由注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送;每个生产者都收到一个 `JobHandle`,其 `append` 把块推进同一个环。模型的消耗式读取与每个观察者的绝对偏移读取服务同一批字节;`readOutput` 与 `record` 判别字段被移除。shell 生产者把它们的 `observed` 偏移读取器交给注册表,并把沙箱事实折进终态 `detail`;terminal 生产者通过一个计字节的源适配其消耗式发送读取器;subagent 把回答作为 `JobOutcome.result` 返回,结算后的第一次读取携带它一次。 +- **两个游标,一份存储。** `VisibleJobs.read(id)` 是有状态的模型游标;`VisibleJobs.readAt(id, from)` 是无状态的观察者读取。模型读取排除 `log` 块(给观察者的生产者叙述),并渲染 stdout,再接一段 `[stderr]`,与 shell 工具一贯的做法完全一致。 +- **`updateProgress` 取代 `updateDetail`。** `JobView.progress` 是实时行,结算时清除;`JobView.detail` 是终态原因,合并了模型 `job_kill` 给出的原因。 +- **一个投影。** `JobView`(客户端安全叶子 `@deepseek-ai/dsh-jobs/view`)是模型工具、浏览器名册与观测帧共同消费的形状;`owner` 是会话 id,从不是 `Agent`。 +- **一个绑定调用方的视图。** `JobRegistry.visibleTo(caller?)` 绑定一次会话并返回 `list`、`get`、`read`、`readAt`、`kill` 与 `wait`;每个方法对调用方集合之外的 id 抛出,不区分未知与他人。 +- **一条事件流。** 带 `{ owner }`、`{ owners: 'scope' }` 或 `{ owners: 'all' }` 过滤的 `events.subscribe(filter, listener)` 取代三个监听器家族。`settled` 标出原因(`producer`、`kill`、`teardown`);`output` 只携带 id 与新的 total。 +- **播报台账移到 `dsh-tool-jobs`。** 注册表不再跟踪 `reported`。工具在等待开始时认领 job(超时或中止则撤回),在 `job_kill` 被接受时也认领;结算事件删除条目,teardown 结算与无主 job 被跳过,投递目标是结算时登记在拥有者会话下的 agent。 +- **名册离开会话控制流。** `dsh-api-job-controller` 在 `job.observe` 旁拥有 `job.rows({ sessionId })`——每轮聚合过的生命周期提交之后发一次整集帧,从不按追加发——其客户端安装 `ctx.jobs`(`watchRows`、`observe`、一份快照)。`dsh-api-session-controller` 只承载队列与投影;`ui-jobs` 在挂载期间关注其会话的名册,行在 job 进行中或留有保留输出时可展开。 + +`attachController` 与相对拥有者的 `servesOwner` 门保持原样;没有挂载控制器的生产者是否可以提供 `run_in_background`,因为这个缺口早于本次变更,暂不处理。 + +## 考虑过的替代方案 + +- **保留三个监听器并共享一个拥有者过滤。** 它保住了堆出来的名字,却保不住评审要的性质——一个地方就能学会注册表宣布什么——而且 `onJobDone` 的精确拥有者投递正是迫使注册表在每个投影里保留 `Agent` 的原因。 +- **把 `reported`留在注册表。** 只有工具知道哪些投递到达了模型(等待的工具结果、kill 的确认);注册表只能近似,而这个近似正是评审列出的重复播报与漏播报 bug 的来源。 +- **把名册留在会话控制流上。** 省了一条 Remote 流,却把会话控制器耦合到 job 注册表,还镜像了一个环的字节数如今直接回答的 `record` 标志(`output.total`)。 +- **也把 `log` 块交给模型。** 生产者叙述是给观察者的;模型已经通过 `progress` 与终态 `detail` 收到同样的事实。 + +## 后果 + +模型可见文本保持不变:状态行、工具描述与 schema、播报措辞逐字节相同,唯一新增的模型可见事实是 `killed` 状态行现在携带的 kill 原因。需要流式输出的生产者选择拉取源或 `append`,从不格式化消耗式增量。Web 客户端把每个进行中的 job 渲染为可展开,因此从不写输出的 job 显示为空的运行中面板而非静态行。`JobKindMap` 的合并发生在宿主侧,浏览器程序把 `JobView.kind` 视为它认识的 kind 集合,其余当作不透明字符串——名册对它也只做这些。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index ee733d059e..8e24662bc9 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 7787466154d65ba0970010c3fdaf964ae6d8507f -2026-08-08-web-background-job-display.zh.md: 478acdf00936baed1700d18db2f63b1da1749e37 +2026-08-08-web-background-job-display.md: d2440ea63fc1d9c7f1829fa18b3054a8ea3768a5 +2026-08-08-web-background-job-display.zh.md: e5e55c95ce94f211ee6d1ac7f3391ab9a453c1a6 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 7787466154..d2440ea63f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -2,6 +2,8 @@ Status: implemented +Superseded: the roster this note put on the session control stream (`jobsBySession`, `onJobsChanged`) now streams from the job controller's `job.rows` into `ctx.jobs` — see [the jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md). The display decisions below (one roster, sections, durations, no kill control) still hold. + Update: the output phase this note deferred now ships as the per-job observation record on `ctx.jobs` — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). English | [中文](2026-08-08-web-background-job-display.zh.md) @@ -119,7 +121,7 @@ A running one-shot background subagent therefore appears both there and in the s The [web e2e scenario](../../../../apps/web/tests/background-job-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.jobs`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer. -Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) pins the complete baseline, three change pushes, dropped internal fields, unowned fan-out, no-resume guarantee, registry-absent composition, and the prohibition on consuming model output; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior. +Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`rows`](../../../../packages/api/job-controller/tests/rows.host.spec.ts) pins the roster stream that replaced the control-stream fan-out: the complete visible set on open, a refresh after each lifecycle commit and none per append, owner removal, and a clean abort; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 478acdf009..e5e55c95ce 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -2,6 +2,8 @@ Status: implemented +Superseded:本文放在会话控制流上的名册(`jobsBySession`、`onJobsChanged`)现在从 job 控制器的 `job.rows` 流入 `ctx.jobs`——见 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)。下文的展示决策(单一名册、分组、时长、没有 kill 控件)仍然成立。 + Update:本文推迟的输出阶段现已作为 `ctx.jobs` 上的逐 job 观测 record 交付——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 [English](2026-08-08-web-background-job-display.md) | 中文 @@ -119,7 +121,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void [web e2e 场景](../../../../apps/web/tests/background-job-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.jobs`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。 -在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) 钉住完整 baseline、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证、没有注册表的组合,以及不得消费模型输出;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。 +在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`rows`](../../../../packages/api/job-controller/tests/rows.host.spec.ts) 钉住取代控制流扇出的名册流:打开时的完整可见集、每次生命周期提交后刷新一次且追加不刷新、拥有者移除,以及干净的中止;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。 ## 影响 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 01617f6564..da0b495dee 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 825dac9cf44bd39f7297d2323b63b9f2bca6aa46 -architecture.zh.md: ca929d52cb20b97b522bf1ca04ff8046da208583 +architecture.md: 07bb03abca22bcef018aefcde9515d42df9f1b7e +architecture.zh.md: 214927d0ebcf8e76a98fc10911b097827e86c7a4 diff --git a/docs/architecture.md b/docs/architecture.md index 825dac9cf4..07bb03abca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,7 +129,7 @@ New behavior attaches to a documented extension point. Changing the loop itself | Add persistent terminal execution | register a `ctx.terminals` backend plus `dsh-tool-terminal` | | Add a human command | register on `ctx.commands`; it dispatches without a model turn | | Add background work | register on `ctx.jobs`; `job_*` tools collect or stop it | -| Stream live output to observers | declare `record: true` on the `ctx.jobs` start; the Web client renders it | +| Stream live output to observers | name pull `output` sources on the `ctx.jobs` spec or push through its `JobHandle`; the Web client renders the ring | | Start a Session from an external webhook | register a trusted rule on `ctx.webhookRuntime` and mount a provider adapter | | Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index ca929d52cb..214927d0eb 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -133,7 +133,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` | | 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 | | 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 | -| 向观察者流式输出 | 在 `ctx.jobs` 启动时声明 `record: true`;Web 客户端负责渲染 | +| 向观察者流式输出 | 在 `ctx.jobs` 的 spec 上声明拉取式 `output` 源,或经其 `JobHandle` 推送;Web 客户端负责渲染输出环 | | 从外部 webhook 启动 Session | 在 `ctx.webhookRuntime` 上注册可信规则,并挂载提供方适配器 | | 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 | | 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 45eda97034..e1becf5027 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: bac3a232db4daaf8f3d251e8bf47e3add1e3e640 -config-catalog.zh.md: 8ad8906e2b558eec8e092971f4ab2aa1d3aec020 +config-catalog.md: 9b6f869107a7a04e282f45af757cdacf50e729b7 +config-catalog.zh.md: e57226dd1ed37f070078894d10c277d914acf437 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index bac3a232db..9b6f869107 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -203,12 +203,12 @@ Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/in ## `@deepseek-ai/dsh-api-job-controller` -Requires: `agents` · `jobs` · `typert` +Requires: `jobs` · `typert` ```ts config-catalog /** Job Controller deployment policy. */ export interface Config { - /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */ + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ readonly observeFlushMs?: number /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ readonly observeMaxFrameBytes?: number @@ -948,14 +948,16 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number - /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + /** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */ retainBytes?: number - /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + /** Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ settledRetainBytes?: number + /** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */ + pumpPollMs?: number } ``` -Source: [`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts) +Source: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) @@ -2645,12 +2647,10 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ - recordPollMs?: number } ``` -Source: [`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) +Source: [`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts) @@ -2751,7 +2751,7 @@ Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/s ## `@deepseek-ai/dsh-tool-jobs` -Requires: `tools` · `jobs` · `systemPrompt` +Requires: `tools` · `jobs` · `agents` · `systemPrompt` ```ts config-catalog /** Configures bounded `job_output` waits and completion-notice delivery. */ @@ -2772,14 +2772,14 @@ export interface Config { } /** - * How an unreported completion reaches an owner that is already idle: `wakeup` + * How an uncollected completion reaches an owner that is already idle: `wakeup` * opens a turn for it, `quiet` leaves it pending until something else wakes the * owner. A busy owner is injected either way. */ export type CompletionDelivery = 'quiet' | 'wakeup' ``` -Source: [`packages/jobs/tool-jobs/src/index.ts:31`](../packages/jobs/tool-jobs/src/index.ts) +Source: [`packages/jobs/tool-jobs/src/index.ts:37`](../packages/jobs/tool-jobs/src/index.ts) @@ -2812,12 +2812,10 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ - recordPollMs?: number } ``` -Source: [`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts) +Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) @@ -3001,7 +2999,7 @@ export interface Config { } ``` -Source: [`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts) +Source: [`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8ad8906e2b..e57226dd1e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -205,12 +205,12 @@ export interface Config { ## `@deepseek-ai/dsh-api-job-controller` -需要:`agents` · `jobs` · `typert` +需要:`jobs` · `typert` ```ts config-catalog /** Job Controller deployment policy. */ export interface Config { - /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */ + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ readonly observeFlushMs?: number /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ readonly observeMaxFrameBytes?: number @@ -952,14 +952,16 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number - /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + /** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */ retainBytes?: number - /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + /** Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ settledRetainBytes?: number + /** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */ + pumpPollMs?: number } ``` -来源:[`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts) +来源:[`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) @@ -2649,12 +2651,10 @@ export type TokenMeterConfig = Record export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ - recordPollMs?: number } ``` -来源:[`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) +来源:[`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts) @@ -2755,7 +2755,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-jobs` -需要:`tools` · `jobs` · `systemPrompt` +需要:`tools` · `jobs` · `agents` · `systemPrompt` ```ts config-catalog /** Configures bounded `job_output` waits and completion-notice delivery. */ @@ -2776,14 +2776,14 @@ export interface Config { } /** - * How an unreported completion reaches an owner that is already idle: `wakeup` + * How an uncollected completion reaches an owner that is already idle: `wakeup` * opens a turn for it, `quiet` leaves it pending until something else wakes the * owner. A busy owner is injected either way. */ export type CompletionDelivery = 'quiet' | 'wakeup' ``` -来源:[`packages/jobs/tool-jobs/src/index.ts:31`](../packages/jobs/tool-jobs/src/index.ts) +来源:[`packages/jobs/tool-jobs/src/index.ts:37`](../packages/jobs/tool-jobs/src/index.ts) @@ -2816,12 +2816,10 @@ export interface Config { export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean - /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ - recordPollMs?: number } ``` -来源:[`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts) +来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) @@ -3005,7 +3003,7 @@ export interface Config { } ``` -来源:[`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts) +来源:[`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts) diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 9d02664a90..b7d90c82c1 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-tool.md -adding-a-tool.md: f6b2703f3db69a6abc04dbd0fd9893555eed0602 -adding-a-tool.zh.md: d4831e2be061c2565e88004062cbb1558491bb9d +adding-a-tool.md: e6d1a9702d21420eeb42d743bd26f7738be2c217 +adding-a-tool.zh.md: a0d35f0b2c821333d82170945aa14f913fa7e034 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index f6b2703f3d..e6d1a9702d 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -52,7 +52,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S Gate `run_in_background` with producer config, then register through `ctx.jobs.start({ kind, label, owner: exec.agent, run })`. The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and task-controller availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as `{ kind: 'background', jobId }`; its Native renderer may keep human prose such as `started background job bash-1`, but PTC mode must never parse that prose to recover the id. -The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. +The spec supplies synchronous `cancel`, a non-rejecting `done` that settles after resource cleanup, and either pull `output` sources the registry pumps into the job's output ring or pushes through the `JobHandle` its starter receives; `dsh-tool-jobs` renders the model's consuming read from that ring. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. ## Execution policy and observation diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index d4831e2be0..a0d35f0b2c 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -52,7 +52,7 @@ export function apply(ctx: Context) { 通过 producer 配置控制 `run_in_background`,然后使用 `ctx.jobs.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前将已预先中止的调用判为失败;运行时会在 `run()` 启动工作前校验 owner 和任务控制器是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。成功的后台分支会返回类型化的规范句柄,如 `{ kind: 'background', jobId }`;其 Native 渲染器可以保留 `started background job bash-1` 这类供人阅读的自然语言,但 PTC mode 绝不能通过解析该文本取得 id。 -producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。 +spec 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及由注册表泵入 job 输出环的拉取式 `output` 源或经 starter 收到的 `JobHandle` 推送的追加;模型的消费式读取由 `dsh-tool-jobs` 从该环渲染。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 9d71df140c..fc09aa63be 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: e0b4709875723bd96f9910b1bf518600612f0a12 -event-producer-consumer.zh.md: 0f0fa1317eb5a017daf435648f5c3e36208626e0 +event-producer-consumer.md: 53f524875794bd6f088adc093e68d47816512098 +event-producer-consumer.zh.md: b07570110077da6c3c775d6c1381cf69ef534c70 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e0b4709875..53f5248757 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:551`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:510`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 0f0fa1317e..b075701100 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:551`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:510`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 77ffa1744e..294e9032a4 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 7d3db9fbf2c078bfcf8ad542cc02cd459c303730 -module-graph.zh.md: 726326b378dccc3b5208b67bf94117fb2f2e3d57 +module-graph.md: ad0a401fe90a7eb7f58b0ca0d81ff311667b0f61 +module-graph.zh.md: a16f710ea2476ef28467e5f6e0a1417c095b80df diff --git a/docs/module-graph.md b/docs/module-graph.md index 7d3db9fbf2..ad0a401fe9 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1048,7 +1048,6 @@ flowchart TD pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection pkg_api_session_controller --> pkg_file_reference - pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command pkg_api_session_controller --> pkg_scope @@ -1383,7 +1382,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 726326b378..a16f710ea2 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1050,7 +1050,6 @@ flowchart TD pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection pkg_api_session_controller --> pkg_file_reference - pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command pkg_api_session_controller --> pkg_scope @@ -1385,7 +1384,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index b6e2f8ac7f..32aec02eef 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 1c0c6919987c691b82c4639aff0f779c95dca83c -persistence-catalog.zh.md: 4cc8ba5b7fc76708a80285013ebcbb03fe3e8e4a +persistence-catalog.md: 55a5596b3f37f31824eac77141abd0f6662ffa33 +persistence-catalog.zh.md: 2971549e08886fe7e39e77daf0785ea9060f9139 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1c0c691998..55a5596b3f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -512,7 +512,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src 'model/selection': ModelSelection ``` -Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) +Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) ### `permission/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4cc8ba5b7f..2971549e08 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -514,7 +514,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) ### `permission/*` diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 8e4c26b4bb..f71f76aa0e 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/jobs.md -jobs.md: 639faa8da3836cb3efc865c494cd8ca8c60f49fb -jobs.zh.md: 3d008671657847520eccd41b8a3bd75014777680 +jobs.md: cdbeb6df0c0f69458bfae89981c8f2b6d3dfc381 +jobs.zh.md: a502fff316a0c2ebccf7888d4eb5aee10de07576 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 639faa8da3..cdbeb6df0c 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -2,7 +2,7 @@ English | [中文](jobs.zh.md) -Types shared by long-running producers, `ctx.jobs`, and job controls. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the exact fields and variants from [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts). +Types shared by long-running producers, `ctx.jobs`, and job controls. The [seam consolidation Agent Note](../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md) owns the current design and the [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) its origin; this page records the exact fields and variants from [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) and the client-safe [`view.ts`](../../packages/jobs/jobs/src/view.ts) leaf. ## Ids and status @@ -19,63 +19,83 @@ interface JobKindMap { } ``` -`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobSnapshot.detail`. +`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobView.progress` while the job runs and in `JobView.detail` once it settled. ## Producer contract -`JobStart` declares identity and a starter, discriminated by `record`: a `RecordingJobStart` receives the `RecordingJob` face, a `PlainJobStart` the `RunningJob` face without `append`. The runtime finishes preflight before calling `run()` with that face and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. +A `JobSpec` declares identity, the owning session, optional pull `output` sources, and a starter. The runtime finishes preflight before calling `run()` with the job's `JobHandle` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, lifecycle state, and the output ring. ```ts type-equiv /** - * A start with the model-facing surfaces only. Its producer face carries no - * `append`, so output the registry would have nowhere to keep is - * unrepresentable rather than dropped. + * Producer declaration passed to {@link JobRegistry.start}. The runtime + * preflights access and cleanup before invoking {@link run}; the producer owns + * execution resources while the runtime owns identity, lifecycle state, and + * the output ring. */ -interface PlainJobStart extends JobStartBase { - record?: undefined +interface JobSpec { + /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ + kind: JobKind + /** One-line model-facing label (the command; the delegation description). */ + label: string + /** + * Owning session. Access is fenced by it, and the owner's live Agent must be + * the one currently registered under that id: its disposal cancels and + * awaits the job. Omitting the owner creates an unowned job, open to any + * caller until service disposal. + */ + owner?: SessionId + /** + * Optional UTF-8 byte cap for each complete model-facing completion notice or + * output read, including controller status metadata. Independent of ring + * retention: it bounds the consuming model surface, never observers. + */ + outputLimitBytes?: number + /** + * Pull sources the registry pumps into the ring at its own cadence. + * Producers that narrate their own progress use {@link JobHandle.append} + * instead; a job may use both. + */ + output?: readonly JobOutputSource[] /** * Start the work after preflight and synchronously return its hooks. Called * once with the job's producer face; a throw leaves nothing registered (the * spent ordinal is skipped), and the producer must clean up any partially * started resources. - * @param job - the issued id plus the live-detail writer. + * @param job - the issued id plus the ring append and progress writers. */ - run(job: RunningJob): JobHooks + run(job: JobHandle): JobHooks } ``` ```ts type-equiv /** - * A start that declares an observable output record beside the model-facing - * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that - * any number of observers read at absolute byte offsets through - * {@link JobRegistry.readRecord}, and snapshots carry - * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + * Producer face of one registered job, handed to {@link JobSpec.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — writes log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. */ -interface RecordingJobStart extends JobStartBase { - record: true +interface JobHandle { + /** The registry-issued id (`-N`). */ + readonly id: JobId /** - * Start the work after preflight and synchronously return its hooks. Called - * once with the job's producer face; a throw leaves nothing registered (the - * spent ordinal is skipped), and the producer must clean up any partially - * started resources. - * @param job - the issued id plus the record append and live-detail writers. + * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 + * byte length; an empty chunk is dropped without waking observers. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. */ - run(job: RecordingJob): JobHooks + append(text: string, options?: JobAppendOptions): void + /** + * Replace the live progress line (`3/10`, the current phase). Settlement + * clears it; the terminal reason travels in {@link JobOutcome.detail}. + * @param line - the new progress line. + */ + updateProgress(line: string): void } ``` -```ts type-equiv -/** - * Producer declaration passed to {@link JobRegistry.start}, discriminated by - * `record`. The runtime preflights access and cleanup before invoking `run`; - * the producer owns execution resources while the runtime owns identity and - * lifecycle state. - */ -type JobStart = PlainJobStart | RecordingJobStart -``` - -`JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. Optional `readOutput` distinguishes consuming stream jobs from final-output-only jobs. +`JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. A job whose result is a value rather than a stream — a subagent's report, a workflow's rendered result — returns it as `JobOutcome.result`; the model's first read after settlement carries it once. ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -89,18 +109,9 @@ interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. Settlement also - * ends the record: fold any record-filling pump into this promise so the - * final drain lands before the registry trims and closes the stream. + * registry record without claiming that the work stopped. */ done: Promise - /** - * Consume output produced since the previous call. The producer formats - * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. Independent of the record: this is the - * model-facing projection, {@link JobRegistry.readRecord} the observer one. - */ - readOutput?(): string } ``` @@ -109,74 +120,82 @@ interface JobHooks { interface JobOutcome { /** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' - /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ + /** + * Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`). + * When the job settles `killed` after a {@link VisibleJobs.kill} with a + * reason, the registry appends that reason. + */ detail?: string - /** Final output for jobs without `readOutput`; stream jobs leave it unset. */ - output?: string + /** + * Return value for jobs whose result is a value rather than a stream (a + * workflow's rendered result, a subagent's report). The output ring carries + * the stream; this is handed out once by the model's next {@link VisibleJobs.read}. + */ + result?: string } ``` -## Observation record - -A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RecordingJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. Browsers reach the record through `job.observe`, the Remote stream of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below. +A pull source hands the registry a non-consuming offset reader — the subprocess `readFrom` family. The registry pumps every source at its own cadence (`pumpPollMs` on `dsh-jobs-local`) and drains it once more before settlement closes the ring, so producers fold nothing into `done`. ```ts type-equiv /** - * Producer face of one registered job, handed to `run` and valid for the - * job's whole life. All methods are synchronous. Writes staged inside the - * starter call are retained and become visible with the registration commit; - * after settlement — the producer's own outcome, a kill, or a registry-forced - * teardown end — writes log and drop instead of throwing, so a producer's - * trailing flush cannot break its own teardown path. + * A pull source the registry pumps into the job's output ring — the subprocess + * `readFrom` family. The registry owns the cadence and drains every source one + * last time before settlement closes the ring, so a producer folds nothing + * into its `done`. */ -interface RunningJob { - /** The registry-issued id (`-N`). */ - readonly id: JobId - /** - * Replace the snapshot's status detail with a live progress line (`3/10`). - * @param detail - the new detail line. - */ - updateDetail(detail: string): void -} -``` - -```ts type-equiv -/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */ -interface RecordingJob extends RunningJob { - /** - * Append one record chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. - * @param text - the chunk text, exactly as produced. - * @param options - stream label and gap marker. - */ - append(text: string, options?: JobAppendOptions): void -} -``` - -```ts type-equiv -/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ -interface JobRecordChunk { - /** Absolute offset of the chunk's first byte. */ - at: number - /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ - text: string - /** Stream label, when the producer supplied one. */ +interface JobOutputSource { + /** Stream label attached to every chunk this source yields. */ channel?: JobChannel - /** Producer-reported loss immediately before this chunk. */ - gapBefore?: true + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, and the lossy flag. + */ + read(fromByte: number): JobSourceRead } ``` ```ts type-equiv -/** Result of one non-consuming {@link JobRegistry.readRecord}. */ -interface JobRecordRead { +/** One incremental read from a {@link JobOutputSource}. */ +interface JobSourceRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean +} +``` + +## The output ring + +Every job owns one bounded ring. Pull sources are pumped into it and `JobHandle.append` pushes land whole; the model consumes the ring through a registry-kept cursor (`VisibleJobs.read`), any number of observers read it at absolute byte offsets (`VisibleJobs.readAt`), and neither disturbs the other. `JobChannel` labels `stdout`, `stderr`, and `log`; `log` is producer narration that reaches observers only, never the model's consuming read. Settlement ends the stream and trims retention to the settled cap — the ring has no separate lifecycle. Browsers reach the roster and the ring through `job.rows` and `job.observe`, the Remote streams of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below. + +```ts type-equiv +/** One chunk of a job's output ring: absolute offset, text, and its provenance. */ +interface JobChunk { + /** Absolute offset of the chunk's first byte; offsets never move once assigned. */ + readonly at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + readonly text: string + /** Stream label, when the producer supplied one. */ + readonly channel?: JobChannel + /** Bytes immediately before this chunk were lost, at the producer or to retention. */ + readonly gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link VisibleJobs.readAt}. */ +interface JobOutputRead { /** Retained chunks overlapping `[from, total)`, in offset order. */ - chunks: readonly JobRecordChunk[] + chunks: readonly JobChunk[] /** - * Offset to resume from — the record's current `outputTotal`. Always a - * chunk boundary: appends land whole and trimming only advances chunk - * starts, and consumers concatenate `chunks` under that assumption, so a - * provider serving partial chunks would silently duplicate text. + * Offset to resume from — the ring's current `total`. Always a chunk + * boundary: appends land whole and trimming only advances chunk starts, and + * consumers concatenate `chunks` under that assumption, so a provider + * serving partial chunks would silently duplicate text. */ next: number /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ @@ -186,78 +205,167 @@ interface JobRecordRead { ## Consumer views -Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another reporter has delivered or committed to deliver the terminal state, including the teardown cancel that drains an owner or the service. +`JobView` is the one projection every reader consumes: the model tools, the browser roster, and the observation stream. `owner` carries the session id that fences access; the registry resolves the live `Agent` behind it for lifecycle cleanup and never hands the object out. `progress` is the producer's live line and is cleared at settlement; `detail` is the terminal reason, with a recorded kill reason merged in. ```ts type-equiv /** - * A read-only projection of one job, safe to hand to listeners and tools — - * a fresh object per call, never live registry state. + * Read-only projection of one job — a fresh object per call, never live + * registry state. The model tools, the browser roster, and the observation + * stream all consume this one shape. */ -interface JobSnapshot { +interface JobView { /** The registry-issued id (`-N`). */ - id: JobId + readonly id: JobId /** The producer kind the job was registered with. */ - kind: JobKind + readonly kind: JobKind /** The producer-supplied one-line label. */ - label: string - /** Producer-owned cap for complete model-facing notices and output reads. */ - outputLimitBytes?: number - /** - * Owner session id used for authorization and correlation; absent for - * unowned jobs. Completion listeners receive the exact {@link Agent} - * separately through {@link JobDoneListener}. - */ - ownerSession?: SessionId + readonly label: string + /** Owning session; absent for an unowned job, which every caller can see. */ + readonly owner?: SessionId + /** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */ + readonly outputLimitBytes?: number /** Current lifecycle state. */ - status: JobStatus - /** - * Kind-specific status detail: live progress while the producer updates it - * through {@link RunningJob.updateDetail}, the terminal detail once settled. - */ - detail?: string + readonly status: JobStatus + /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */ + readonly progress?: string + /** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */ + readonly detail?: string /** Epoch ms when the job was registered. */ - startedAt: number - /** Epoch ms when the job settled; absent while `running`/`stopping`. */ - finishedAt?: number + readonly startedAt: number + /** Epoch ms when the job settled; absent while live. */ + readonly finishedAt?: number /** - * True when a kill, read, wait, or teardown cancel has reported or committed - * to report the terminal state. Completion reporters suppress redundant - * notices when set. Teardown claims it because the owner or service being - * destroyed leaves no reader: a reporter that opens a turn on notice would - * otherwise spend a model request per teardown layer. + * The output ring's absolute coordinates. `total` is the offset the next + * chunk starts at (0 while nothing was written); `earliest` is the oldest + * retained byte, greater than zero exactly when retention dropped the head. */ - reported: boolean - /** - * Total UTF-8 bytes ever appended to the record — the offset the next chunk - * starts at. Present exactly when the job declared {@link JobStart.record}. - */ - outputTotal?: number - /** - * Offset of the oldest retained record byte. Greater than zero exactly when - * retention dropped the head; a reader starting below it gets a lossy read. - * Present exactly when the job declared {@link JobStart.record}. - */ - outputEarliest?: number + readonly output: { readonly total: number; readonly earliest: number } +} +``` + +`JobRegistry.visibleTo(caller)` binds one caller's identity and returns its operations; a caller sees its own jobs and every unowned job, and every operation throws for an id outside that set. + +```ts type-equiv +/** Output and post-read state returned by the consuming {@link VisibleJobs.read}. */ +interface JobRead { + /** Ring chunks appended since the model cursor, in offset order; every channel included. */ + chunks: readonly JobChunk[] + /** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean + /** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */ + result?: string + /** The job's state at read time. */ + job: JobView } ``` ```ts type-equiv -/** Output and post-read state returned by {@link JobRegistry.read}. */ -interface JobRead { +/** + * The operations one caller may perform, bound by {@link JobRegistry.visibleTo}. + * A caller sees its own jobs and every unowned job; every method throws for + * an id outside that set, without distinguishing unknown from foreign. + */ +interface VisibleJobs { /** - * Stream kinds: the consuming delta since the previous read. Final-output - * kinds: empty while live, the terminal {@link JobOutcome.output} (or - * empty) once settled — idempotent, never consumed. + * List the visible jobs in registration order. + * @returns fresh projections. */ - text: string - /** The job's state at read time. */ - snapshot: JobSnapshot + list(): JobView[] + /** + * Project one job without touching its cursor. + * @param id - job to look up. + * @returns a fresh projection. + */ + get(id: JobId): JobView + /** + * Consume the ring from the model cursor and advance it to the current + * total. After settlement the first read also carries the producer's + * result; later reads return only what was appended since. + * @param id - job to read. + * @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection. + */ + read(id: JobId): JobRead + /** + * Read retained ring output from an absolute byte offset without consuming + * it. Resume by passing a previous read's `next`; an offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Never moves the model cursor. Throws for a negative or non-integer offset. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ + readAt(id: JobId, from: number): JobOutputRead + /** + * Request cancellation, then mark the job stopping. A recorded + * {@link JobKillOptions.reason} merges into the terminal `detail` when the + * job settles `killed`. A producer throw propagates without changing job + * state. + * @param id - job to cancel. + * @param options - cancellation reason. + * @returns `requested` for live work, otherwise `already-finished`. + */ + kill(id: JobId, options?: JobKillOptions): 'requested' | 'already-finished' + /** + * Wait for settlement or timeout without cancelling the job. Caller abort + * rejects only while the job is live; after settlement the terminal + * projection wins. Throws for an invalid timeout. + * @param id - job to wait for. + * @param timeoutMs - positive finite wait bound in milliseconds. + * @param signal - optional cancellation of the wait itself. + * @returns projection at settlement or timeout. + */ + wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise } ``` +## Events + +The registry announces every commit through one filtered stream. Lifecycle events carry the projection after the commit they announce; `settled` names its cause so a completion reporter can skip a teardown; `output` carries only the id and the new total, so observers read from their own cursor and the registry never pushes payloads. + +```ts type-equiv +/** + * One lifecycle or output event. Lifecycle events carry the job's projection + * after the commit they announce; `output` carries only the id and the new + * total, so an observer schedules a {@link VisibleJobs.readAt} from its own + * cursor and the registry never pushes payloads. + */ +type JobEvent = + | { + /** Registration commit, progress line change, stopping transition, or removal from the visible set. */ + readonly type: 'registered' | 'progress' | 'stopping' | 'removed' + readonly job: JobView + } + | { + readonly type: 'settled' + readonly job: JobView + readonly cause: JobSettleCause + } + | { + readonly type: 'output' + readonly id: JobId + /** Owning session, absent for an unowned job. */ + readonly owner?: SessionId + /** The ring's total after the append (or at settlement, which ends the stream). */ + readonly total: number + } +``` + +```ts type-equiv +/** + * Who a subscription hears about. `{ owner }` delivers that session's jobs + * plus every unowned job (the set that session can see). `{ owners: 'scope' }` + * delivers the owners composed under the subscribing context — one registry + * serves every composition in the process, and a mount under one preset must + * not hear another preset's agents. `{ owners: 'all' }` delivers everything. + */ +type JobEventFilter = + | { readonly owner: SessionId } + | { readonly owners: 'all' | 'scope' } +``` + ## Service behavior -The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, non-consuming `readRecord`, `kill`, bounded `wait`, failure-isolated `onJobDone`, `onJobsChanged`, and `onOutput` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity, and `retainBytes` (default 262144) with `settledRetainBytes` (default 16384) bound each declared record's live and settled retention. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. +The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-bound `visibleTo` (`list`, `get`, the consuming `read`, the non-consuming `readAt`, `kill`, and bounded `wait`), the filtered `events` stream, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the live `Agent` registered under the owner session when the job starts. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity; `retainBytes` (default 262144) and `settledRetainBytes` (default 16384) bound each ring's live and settled retention, and `pumpPollMs` (default 150) is the pull cadence. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. @@ -275,11 +383,21 @@ Host service backing the generated `ctx.remote.job` namespace. ```ts cordis-catalog /** - * Stream one job's retained record output from an absolute byte offset, - * then its terminal status once settled and drained. Non-consuming: the + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ +@Remote({ mode: 'stream' }) rows(request: JobRowsRequest, signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the * model-facing cursor and notice state never observe these reads. The - * request's session resolves the fenced-read caller; the registry rejects a - * job the session does not own, a job without a record, or an unknown job. + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. * @param request - target job, owning session, and optional resume offset. * @param signal - cancellation owned by the Remote stream carrier. * @returns anchor, coalesced output frames, and the terminal status. @@ -293,16 +411,16 @@ Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-cont ### `ctx.jobs` — `JobRegistry` (abstract seam) -Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). +Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). Implementations must honor these semantics: -- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left. +- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left. - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. -- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. -- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. -- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected. -- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle. +- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event. +- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other. +- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle. ```ts cordis-catalog /** @@ -310,120 +428,20 @@ Implementations must honor these semantics: * admission before starting and atomically registering work. Any preflight * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. - * Settlement records the outcome, notifies listeners, and releases waiters. - * @param spec - job identity, owner, and synchronous starter. + * @param spec - job identity, owner, output sources, and synchronous starter. * @returns the registry-issued `-N` id. */ -abstract start(spec: JobStart): JobId +abstract start(spec: JobSpec): JobId /** - * List caller-owned and unowned jobs in registration order without exposing - * another session's labels. - * @param caller - reading agent; a non-agent caller sees only unowned jobs. - * @returns fresh snapshots. + * Bind the caller's identity once and return its operations. A caller sees + * its own jobs and every unowned job; a caller-less view sees unowned jobs + * only. The view resolves visibility on every call, so it stays valid as + * jobs come and go. + * @param caller - the reading session, or undefined for an anonymous caller. + * @returns the operations available to that caller. */ -abstract list(caller?: Agent): JobSnapshot[] - -/** - * Return a non-consuming snapshot without changing its read cursor or notice - * state. Throws for an unknown or foreign job. - * @param id - job to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ -abstract get(id: JobId, caller?: Agent): JobSnapshot - -/** - * Read the next stream delta, or the idempotent final output after settlement. - * A terminal read marks the job reported. Throws for an unknown or foreign - * job. - * @param id - job to read. - * @param caller - reading agent checked against the owner. - * @returns output text and the post-read snapshot. - */ -abstract read(id: JobId, caller?: Agent): JobRead - -/** - * Request cancellation, then mark the job stopping and reported. A producer - * throw propagates without changing job state. Throws for an unknown or - * foreign job. - * @param id - job to cancel. - * @param caller - killing agent checked against the owner. - * @param reason - logged reason forwarded to the producer. - * @returns `requested` for live work, otherwise `already-finished`. - */ -abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' - -/** - * Wait for settlement or timeout without cancelling the job. Caller abort - * rejects only while the job is live; after settlement the terminal - * snapshot wins so a notice suppressed for this waiter is still delivered. - * Throws for invalid, unknown, or foreign input. - * @param id - job to wait for. - * @param timeoutMs - positive finite wait bound in milliseconds. - * @param caller - waiting agent checked against the owner. - * @param signal - optional cancellation of the wait itself. - * @returns snapshot at settlement or timeout. - */ -abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise - -/** - * Register an effect-scoped completion listener. It receives the settlements - * of the owners its registering context's scope covers; each listener is - * contained; returned promises are observed but not awaited. No listener runs - * after service disposal. - * @param listener - receives each terminal snapshot and its exact owner. - * @returns disposer that unregisters the listener. - */ -abstract onJobDone(listener: JobDoneListener): () => void - -/** - * Register an effect-scoped observer of visible-set changes. It fires after - * every commit that changes what {@link list} returns for that owner — - * registration, every stopping transition (including the one teardown - * performs before it awaits a slow producer), settlement, owner-disposal - * removal, and the emptying that service disposal commits — so an observer - * re-reads rather than accumulating deltas. - * - * Delivery is owner-relative on the same terms as {@link onJobDone}: an - * observer registered from an unscoped context — a host composition's own - * carrier — sees every owner, while one registered under an agent - * composition's scope sees exactly the agents composed under it. - * - * This is not a superset of {@link onJobDone}: that one delivers the terminal - * record under first-wins semantics a job controller couples to notice - * delivery, while this one carries no delivery meaning and marks nothing - * reported. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned job changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ -abstract onJobsChanged(listener: JobsChangedListener): () => void - -/** - * Read retained record output from an absolute byte offset without consuming - * it. Resume by passing a previous read's `next`; a foreign offset inside a - * retained chunk returns that whole chunk (its `at` may precede `from`). - * Never marks the job reported. Throws for an unknown or foreign job, a job - * without a {@link JobStart.record} declaration, or a negative or - * non-integer offset. - * @param id - job to read. - * @param from - absolute byte offset to read from (0 for the retained head). - * @param caller - reading agent checked against the owner. - * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. - */ -abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead - -/** - * Register an effect-scoped observer of record advancement — one signal per - * committed append and one at settlement, carrying only the job id. A - * consumer schedules a {@link readRecord} from its own cursor; the registry - * never pushes payloads. Delivery is owner-relative on the same terms as - * {@link onJobsChanged}. Listeners are contained and never awaited. - * @param listener - receives the id whose record advanced. - * @returns disposer that unregisters the listener. - */ -abstract onOutput(listener: JobOutputListener): () => void +abstract visibleTo(caller?: SessionId): VisibleJobs /** * Attach an effect-scoped controller that can read and stop jobs. It serves the @@ -435,7 +453,7 @@ abstract onOutput(listener: JobOutputListener): () => void abstract attachController(name: string): () => void ``` -Types: [Agent](core.md) +Types: [SessionId](core.md) Source: [`packages/jobs/jobs/src/index.ts`](../../packages/jobs/jobs/src/index.ts) diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 3d00867165..a502fff316 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -2,7 +2,7 @@ [English](jobs.md) | 中文 -长时间运行的生产方、`ctx.jobs` 与任务控制命令共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md) 负责设计;本页记录 [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) 中的确切字段和变体。 +长时间运行的生产方、`ctx.jobs` 与任务控制命令共用的类型。[seam 收敛 Agent Note](../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md) 负责当前设计,[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md) 记录其起源;本页记录 [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) 与客户端安全叶子 [`view.ts`](../../packages/jobs/jobs/src/view.ts) 中的确切字段与变体。 ## ID 与状态 @@ -19,63 +19,83 @@ interface JobKindMap { } ``` -`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实归入 `JobSnapshot.detail`。 +`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实在运行期间归入 `JobView.progress`,结算后归入 `JobView.detail`。 ## 生产方约定 -`JobStart` 声明身份和启动器,并按 `record` 判别:`RecordingJobStart` 收到 `RecordingJob` 面,`PlainJobStart` 收到没有 `append` 的 `RunningJob` 面。运行时会在完成预检后携带该面调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 +`JobSpec` 声明身份、拥有者会话、可选的拉取式 `output` 源与启动器。运行时会在完成预检后携带该 job 的 `JobHandle` 调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问、生命周期状态与输出环。 ```ts type-equiv /** - * A start with the model-facing surfaces only. Its producer face carries no - * `append`, so output the registry would have nowhere to keep is - * unrepresentable rather than dropped. + * Producer declaration passed to {@link JobRegistry.start}. The runtime + * preflights access and cleanup before invoking {@link run}; the producer owns + * execution resources while the runtime owns identity, lifecycle state, and + * the output ring. */ -interface PlainJobStart extends JobStartBase { - record?: undefined +interface JobSpec { + /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ + kind: JobKind + /** One-line model-facing label (the command; the delegation description). */ + label: string + /** + * Owning session. Access is fenced by it, and the owner's live Agent must be + * the one currently registered under that id: its disposal cancels and + * awaits the job. Omitting the owner creates an unowned job, open to any + * caller until service disposal. + */ + owner?: SessionId + /** + * Optional UTF-8 byte cap for each complete model-facing completion notice or + * output read, including controller status metadata. Independent of ring + * retention: it bounds the consuming model surface, never observers. + */ + outputLimitBytes?: number + /** + * Pull sources the registry pumps into the ring at its own cadence. + * Producers that narrate their own progress use {@link JobHandle.append} + * instead; a job may use both. + */ + output?: readonly JobOutputSource[] /** * Start the work after preflight and synchronously return its hooks. Called * once with the job's producer face; a throw leaves nothing registered (the * spent ordinal is skipped), and the producer must clean up any partially * started resources. - * @param job - the issued id plus the live-detail writer. + * @param job - the issued id plus the ring append and progress writers. */ - run(job: RunningJob): JobHooks + run(job: JobHandle): JobHooks } ``` ```ts type-equiv /** - * A start that declares an observable output record beside the model-facing - * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that - * any number of observers read at absolute byte offsets through - * {@link JobRegistry.readRecord}, and snapshots carry - * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + * Producer face of one registered job, handed to {@link JobSpec.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — writes log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. */ -interface RecordingJobStart extends JobStartBase { - record: true +interface JobHandle { + /** The registry-issued id (`-N`). */ + readonly id: JobId /** - * Start the work after preflight and synchronously return its hooks. Called - * once with the job's producer face; a throw leaves nothing registered (the - * spent ordinal is skipped), and the producer must clean up any partially - * started resources. - * @param job - the issued id plus the record append and live-detail writers. + * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 + * byte length; an empty chunk is dropped without waking observers. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. */ - run(job: RecordingJob): JobHooks + append(text: string, options?: JobAppendOptions): void + /** + * Replace the live progress line (`3/10`, the current phase). Settlement + * clears it; the terminal reason travels in {@link JobOutcome.detail}. + * @param line - the new progress line. + */ + updateProgress(line: string): void } ``` -```ts type-equiv -/** - * Producer declaration passed to {@link JobRegistry.start}, discriminated by - * `record`. The runtime preflights access and cleanup before invoking `run`; - * the producer owns execution resources while the runtime owns identity and - * lifecycle state. - */ -type JobStart = PlainJobStart | RecordingJobStart -``` - -`JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 +`JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。结果是一个值而非流的 job——subagent 的报告、workflow 渲染出的结果——把它作为 `JobOutcome.result` 返回;模型在结算后的第一次读取携带它一次。 ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -89,18 +109,9 @@ interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. Settlement also - * ends the record: fold any record-filling pump into this promise so the - * final drain lands before the registry trims and closes the stream. + * registry record without claiming that the work stopped. */ done: Promise - /** - * Consume output produced since the previous call. The producer formats - * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. Independent of the record: this is the - * model-facing projection, {@link JobRegistry.readRecord} the observer one. - */ - readOutput?(): string } ``` @@ -109,74 +120,82 @@ interface JobHooks { interface JobOutcome { /** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' - /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ + /** + * Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`). + * When the job settles `killed` after a {@link VisibleJobs.kill} with a + * reason, the registry appends that reason. + */ detail?: string - /** Final output for jobs without `readOutput`; stream jobs leave it unset. */ - output?: string + /** + * Return value for jobs whose result is a value rather than a stream (a + * workflow's rendered result, a subagent's report). The output ring carries + * the stream; this is handed out once by the model's next {@link VisibleJobs.read}. + */ + result?: string } ``` -## 观测 record - -声明 `record: true` 的生产方通过 starter 收到的 `RecordingJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。浏览器经 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.observe` 读取 record,其帧类型列在下方该控制器的 Cordis API 一节。 +拉取源把一个非消耗的偏移读取器——子进程的 `readFrom` 家族——交给注册表。注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送每个源,并在结算封环之前再排干一次,因此生产方不需要把任何东西折进 `done`。 ```ts type-equiv /** - * Producer face of one registered job, handed to `run` and valid for the - * job's whole life. All methods are synchronous. Writes staged inside the - * starter call are retained and become visible with the registration commit; - * after settlement — the producer's own outcome, a kill, or a registry-forced - * teardown end — writes log and drop instead of throwing, so a producer's - * trailing flush cannot break its own teardown path. + * A pull source the registry pumps into the job's output ring — the subprocess + * `readFrom` family. The registry owns the cadence and drains every source one + * last time before settlement closes the ring, so a producer folds nothing + * into its `done`. */ -interface RunningJob { - /** The registry-issued id (`-N`). */ - readonly id: JobId - /** - * Replace the snapshot's status detail with a live progress line (`3/10`). - * @param detail - the new detail line. - */ - updateDetail(detail: string): void -} -``` - -```ts type-equiv -/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */ -interface RecordingJob extends RunningJob { - /** - * Append one record chunk. Offsets advance by the chunk's UTF-8 byte - * length; an empty chunk is dropped without waking observers. - * @param text - the chunk text, exactly as produced. - * @param options - stream label and gap marker. - */ - append(text: string, options?: JobAppendOptions): void -} -``` - -```ts type-equiv -/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ -interface JobRecordChunk { - /** Absolute offset of the chunk's first byte. */ - at: number - /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ - text: string - /** Stream label, when the producer supplied one. */ +interface JobOutputSource { + /** Stream label attached to every chunk this source yields. */ channel?: JobChannel - /** Producer-reported loss immediately before this chunk. */ - gapBefore?: true + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, and the lossy flag. + */ + read(fromByte: number): JobSourceRead } ``` ```ts type-equiv -/** Result of one non-consuming {@link JobRegistry.readRecord}. */ -interface JobRecordRead { +/** One incremental read from a {@link JobOutputSource}. */ +interface JobSourceRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean +} +``` + +## 输出环 + +每个 job 拥有一个有界的环。拉取源被泵入其中,`JobHandle.append` 的推送整块落地;模型通过注册表保管的游标(`VisibleJobs.read`)消耗该环,任意数量的观察者按绝对字节偏移读取它(`VisibleJobs.readAt`),二者互不干扰。`JobChannel` 标记 `stdout`、`stderr` 与 `log`;`log` 是只到达观察者、从不进入模型消耗式读取的生产方叙述。结算即封流并把保留量裁剪到结算上限——环没有独立的生命周期。浏览器通过 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.rows` 与 `job.observe` 触达名册与环,其帧列于下文的 Cordis API 一节。 + +```ts type-equiv +/** One chunk of a job's output ring: absolute offset, text, and its provenance. */ +interface JobChunk { + /** Absolute offset of the chunk's first byte; offsets never move once assigned. */ + readonly at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + readonly text: string + /** Stream label, when the producer supplied one. */ + readonly channel?: JobChannel + /** Bytes immediately before this chunk were lost, at the producer or to retention. */ + readonly gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link VisibleJobs.readAt}. */ +interface JobOutputRead { /** Retained chunks overlapping `[from, total)`, in offset order. */ - chunks: readonly JobRecordChunk[] + chunks: readonly JobChunk[] /** - * Offset to resume from — the record's current `outputTotal`. Always a - * chunk boundary: appends land whole and trimming only advances chunk - * starts, and consumers concatenate `chunks` under that assumption, so a - * provider serving partial chunks would silently duplicate text. + * Offset to resume from — the ring's current `total`. Always a chunk + * boundary: appends land whole and trimming only advances chunk starts, and + * consumers concatenate `chunks` under that assumption, so a provider + * serving partial chunks would silently duplicate text. */ next: number /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ @@ -186,78 +205,167 @@ interface JobRecordRead { ## 消费方视图 -快照是每次新建的只读投影。`ownerSession` 携带用于授权的共享 `SessionId`;完成监听器则会另行收到用于生命周期清理的确切拥有者对象。另一个接口已经交付终止状态或承诺交付时,`reported` 会抑制完成通知;排空 owner 或服务的 teardown 取消同样计入。 +`JobView` 是每个读者都消费的唯一投影:模型工具、浏览器名册与观测流。`owner` 携带划定访问边界的会话 id;注册表为生命周期清理解析其背后的活体 `Agent`,从不把该对象交出去。`progress` 是生产方的实时行,结算时清除;`detail` 是终态原因,合并了记录下来的 kill 原因。 ```ts type-equiv /** - * A read-only projection of one job, safe to hand to listeners and tools — - * a fresh object per call, never live registry state. + * Read-only projection of one job — a fresh object per call, never live + * registry state. The model tools, the browser roster, and the observation + * stream all consume this one shape. */ -interface JobSnapshot { +interface JobView { /** The registry-issued id (`-N`). */ - id: JobId + readonly id: JobId /** The producer kind the job was registered with. */ - kind: JobKind + readonly kind: JobKind /** The producer-supplied one-line label. */ - label: string - /** Producer-owned cap for complete model-facing notices and output reads. */ - outputLimitBytes?: number - /** - * Owner session id used for authorization and correlation; absent for - * unowned jobs. Completion listeners receive the exact {@link Agent} - * separately through {@link JobDoneListener}. - */ - ownerSession?: SessionId + readonly label: string + /** Owning session; absent for an unowned job, which every caller can see. */ + readonly owner?: SessionId + /** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */ + readonly outputLimitBytes?: number /** Current lifecycle state. */ - status: JobStatus - /** - * Kind-specific status detail: live progress while the producer updates it - * through {@link RunningJob.updateDetail}, the terminal detail once settled. - */ - detail?: string + readonly status: JobStatus + /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */ + readonly progress?: string + /** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */ + readonly detail?: string /** Epoch ms when the job was registered. */ - startedAt: number - /** Epoch ms when the job settled; absent while `running`/`stopping`. */ - finishedAt?: number + readonly startedAt: number + /** Epoch ms when the job settled; absent while live. */ + readonly finishedAt?: number /** - * True when a kill, read, wait, or teardown cancel has reported or committed - * to report the terminal state. Completion reporters suppress redundant - * notices when set. Teardown claims it because the owner or service being - * destroyed leaves no reader: a reporter that opens a turn on notice would - * otherwise spend a model request per teardown layer. + * The output ring's absolute coordinates. `total` is the offset the next + * chunk starts at (0 while nothing was written); `earliest` is the oldest + * retained byte, greater than zero exactly when retention dropped the head. */ - reported: boolean - /** - * Total UTF-8 bytes ever appended to the record — the offset the next chunk - * starts at. Present exactly when the job declared {@link JobStart.record}. - */ - outputTotal?: number - /** - * Offset of the oldest retained record byte. Greater than zero exactly when - * retention dropped the head; a reader starting below it gets a lossy read. - * Present exactly when the job declared {@link JobStart.record}. - */ - outputEarliest?: number + readonly output: { readonly total: number; readonly earliest: number } +} +``` + +`JobRegistry.visibleTo(caller)` 绑定一个调用方的身份并返回其操作;调用方看得到自己的 job 与所有无主 job,每个操作对该集合之外的 id 抛出。 + +```ts type-equiv +/** Output and post-read state returned by the consuming {@link VisibleJobs.read}. */ +interface JobRead { + /** Ring chunks appended since the model cursor, in offset order; every channel included. */ + chunks: readonly JobChunk[] + /** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean + /** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */ + result?: string + /** The job's state at read time. */ + job: JobView } ``` ```ts type-equiv -/** Output and post-read state returned by {@link JobRegistry.read}. */ -interface JobRead { +/** + * The operations one caller may perform, bound by {@link JobRegistry.visibleTo}. + * A caller sees its own jobs and every unowned job; every method throws for + * an id outside that set, without distinguishing unknown from foreign. + */ +interface VisibleJobs { /** - * Stream kinds: the consuming delta since the previous read. Final-output - * kinds: empty while live, the terminal {@link JobOutcome.output} (or - * empty) once settled — idempotent, never consumed. + * List the visible jobs in registration order. + * @returns fresh projections. */ - text: string - /** The job's state at read time. */ - snapshot: JobSnapshot + list(): JobView[] + /** + * Project one job without touching its cursor. + * @param id - job to look up. + * @returns a fresh projection. + */ + get(id: JobId): JobView + /** + * Consume the ring from the model cursor and advance it to the current + * total. After settlement the first read also carries the producer's + * result; later reads return only what was appended since. + * @param id - job to read. + * @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection. + */ + read(id: JobId): JobRead + /** + * Read retained ring output from an absolute byte offset without consuming + * it. Resume by passing a previous read's `next`; an offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Never moves the model cursor. Throws for a negative or non-integer offset. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ + readAt(id: JobId, from: number): JobOutputRead + /** + * Request cancellation, then mark the job stopping. A recorded + * {@link JobKillOptions.reason} merges into the terminal `detail` when the + * job settles `killed`. A producer throw propagates without changing job + * state. + * @param id - job to cancel. + * @param options - cancellation reason. + * @returns `requested` for live work, otherwise `already-finished`. + */ + kill(id: JobId, options?: JobKillOptions): 'requested' | 'already-finished' + /** + * Wait for settlement or timeout without cancelling the job. Caller abort + * rejects only while the job is live; after settlement the terminal + * projection wins. Throws for an invalid timeout. + * @param id - job to wait for. + * @param timeoutMs - positive finite wait bound in milliseconds. + * @param signal - optional cancellation of the wait itself. + * @returns projection at settlement or timeout. + */ + wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise } ``` +## 事件 + +注册表通过一条带过滤的流宣布每次提交。生命周期事件携带其所宣布的提交之后的投影;`settled` 标出原因,完成播报方据此跳过 teardown;`output` 只携带 id 与新的 total,观察者从自己的游标读取,注册表从不推送负载。 + +```ts type-equiv +/** + * One lifecycle or output event. Lifecycle events carry the job's projection + * after the commit they announce; `output` carries only the id and the new + * total, so an observer schedules a {@link VisibleJobs.readAt} from its own + * cursor and the registry never pushes payloads. + */ +type JobEvent = + | { + /** Registration commit, progress line change, stopping transition, or removal from the visible set. */ + readonly type: 'registered' | 'progress' | 'stopping' | 'removed' + readonly job: JobView + } + | { + readonly type: 'settled' + readonly job: JobView + readonly cause: JobSettleCause + } + | { + readonly type: 'output' + readonly id: JobId + /** Owning session, absent for an unowned job. */ + readonly owner?: SessionId + /** The ring's total after the append (or at settlement, which ends the stream). */ + readonly total: number + } +``` + +```ts type-equiv +/** + * Who a subscription hears about. `{ owner }` delivers that session's jobs + * plus every unowned job (the set that session can see). `{ owners: 'scope' }` + * delivers the owners composed under the subscribing context — one registry + * serves every composition in the process, and a mount under one preset must + * not hear another preset's agents. `{ owners: 'all' }` delivers everything. + */ +type JobEventFilter = + | { readonly owner: SessionId } + | { readonly owners: 'all' | 'scope' } +``` + ## 服务行为 -抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、按调用方划定的 `get` 与 `list`、`read`、非消耗的 `readRecord`、`kill`、有界的 `wait`、故障隔离的 `onJobDone`、`onJobsChanged` 与 `onOutput` 监听器,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较 owner 会话;owner 清理与准入使用注册在案的确切 `Agent` 实例。本地 provider 的正安全整数配置 `maxConcurrentJobsPerOwner` 默认 `10`,按确切 owner 统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方终态结算释放容量,`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个已声明 record 的运行期与结算后保留量。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md)(Service Definition 契约)、[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md)(注册表生命周期与准入策略)与 [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md)(模型侧 Consumer)。 +抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、绑定调用方的 `visibleTo`(`list`、`get`、消耗式 `read`、非消耗的 `readAt`、`kill` 与有界的 `wait`)、带过滤的 `events` 流,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较拥有者会话;拥有者清理与准入使用 job 启动时登记在该拥有者会话下的活体 `Agent`。本地提供方的正安全整数配置 `maxConcurrentJobsPerOwner` 默认为 `10`,按精确拥有者统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方的终态结算释放容量;`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个环的运行期与结算后保留量,`pumpPollMs`(默认 150)是拉取节奏。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md) 了解 Service Definition 约定,[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md) 了解注册表生命周期与准入策略,[`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md) 了解面向模型的 Consumer。 @@ -275,11 +383,21 @@ Host service backing the generated `ctx.remote.job` namespace. ```ts cordis-catalog /** - * Stream one job's retained record output from an absolute byte offset, - * then its terminal status once settled and drained. Non-consuming: the + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ +@Remote({ mode: 'stream' }) rows(request: JobRowsRequest, signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the * model-facing cursor and notice state never observe these reads. The - * request's session resolves the fenced-read caller; the registry rejects a - * job the session does not own, a job without a record, or an unknown job. + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. * @param request - target job, owning session, and optional resume offset. * @param signal - cancellation owned by the Remote stream carrier. * @returns anchor, coalesced output frames, and the terminal status. @@ -293,16 +411,16 @@ Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-cont ### `ctx.jobs` — `JobRegistry` (abstract seam) -Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). +Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). Implementations must honor these semantics: -- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left. +- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left. - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. -- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. -- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. -- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected. -- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle. +- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event. +- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other. +- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle. ```ts cordis-catalog /** @@ -310,120 +428,20 @@ Implementations must honor these semantics: * admission before starting and atomically registering work. Any preflight * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. - * Settlement records the outcome, notifies listeners, and releases waiters. - * @param spec - job identity, owner, and synchronous starter. + * @param spec - job identity, owner, output sources, and synchronous starter. * @returns the registry-issued `-N` id. */ -abstract start(spec: JobStart): JobId +abstract start(spec: JobSpec): JobId /** - * List caller-owned and unowned jobs in registration order without exposing - * another session's labels. - * @param caller - reading agent; a non-agent caller sees only unowned jobs. - * @returns fresh snapshots. + * Bind the caller's identity once and return its operations. A caller sees + * its own jobs and every unowned job; a caller-less view sees unowned jobs + * only. The view resolves visibility on every call, so it stays valid as + * jobs come and go. + * @param caller - the reading session, or undefined for an anonymous caller. + * @returns the operations available to that caller. */ -abstract list(caller?: Agent): JobSnapshot[] - -/** - * Return a non-consuming snapshot without changing its read cursor or notice - * state. Throws for an unknown or foreign job. - * @param id - job to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ -abstract get(id: JobId, caller?: Agent): JobSnapshot - -/** - * Read the next stream delta, or the idempotent final output after settlement. - * A terminal read marks the job reported. Throws for an unknown or foreign - * job. - * @param id - job to read. - * @param caller - reading agent checked against the owner. - * @returns output text and the post-read snapshot. - */ -abstract read(id: JobId, caller?: Agent): JobRead - -/** - * Request cancellation, then mark the job stopping and reported. A producer - * throw propagates without changing job state. Throws for an unknown or - * foreign job. - * @param id - job to cancel. - * @param caller - killing agent checked against the owner. - * @param reason - logged reason forwarded to the producer. - * @returns `requested` for live work, otherwise `already-finished`. - */ -abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' - -/** - * Wait for settlement or timeout without cancelling the job. Caller abort - * rejects only while the job is live; after settlement the terminal - * snapshot wins so a notice suppressed for this waiter is still delivered. - * Throws for invalid, unknown, or foreign input. - * @param id - job to wait for. - * @param timeoutMs - positive finite wait bound in milliseconds. - * @param caller - waiting agent checked against the owner. - * @param signal - optional cancellation of the wait itself. - * @returns snapshot at settlement or timeout. - */ -abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise - -/** - * Register an effect-scoped completion listener. It receives the settlements - * of the owners its registering context's scope covers; each listener is - * contained; returned promises are observed but not awaited. No listener runs - * after service disposal. - * @param listener - receives each terminal snapshot and its exact owner. - * @returns disposer that unregisters the listener. - */ -abstract onJobDone(listener: JobDoneListener): () => void - -/** - * Register an effect-scoped observer of visible-set changes. It fires after - * every commit that changes what {@link list} returns for that owner — - * registration, every stopping transition (including the one teardown - * performs before it awaits a slow producer), settlement, owner-disposal - * removal, and the emptying that service disposal commits — so an observer - * re-reads rather than accumulating deltas. - * - * Delivery is owner-relative on the same terms as {@link onJobDone}: an - * observer registered from an unscoped context — a host composition's own - * carrier — sees every owner, while one registered under an agent - * composition's scope sees exactly the agents composed under it. - * - * This is not a superset of {@link onJobDone}: that one delivers the terminal - * record under first-wins semantics a job controller couples to notice - * delivery, while this one carries no delivery meaning and marks nothing - * reported. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned job changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ -abstract onJobsChanged(listener: JobsChangedListener): () => void - -/** - * Read retained record output from an absolute byte offset without consuming - * it. Resume by passing a previous read's `next`; a foreign offset inside a - * retained chunk returns that whole chunk (its `at` may precede `from`). - * Never marks the job reported. Throws for an unknown or foreign job, a job - * without a {@link JobStart.record} declaration, or a negative or - * non-integer offset. - * @param id - job to read. - * @param from - absolute byte offset to read from (0 for the retained head). - * @param caller - reading agent checked against the owner. - * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. - */ -abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead - -/** - * Register an effect-scoped observer of record advancement — one signal per - * committed append and one at settlement, carrying only the job id. A - * consumer schedules a {@link readRecord} from its own cursor; the registry - * never pushes payloads. Delivery is owner-relative on the same terms as - * {@link onJobsChanged}. Listeners are contained and never awaited. - * @param listener - receives the id whose record advanced. - * @returns disposer that unregisters the listener. - */ -abstract onOutput(listener: JobOutputListener): () => void +abstract visibleTo(caller?: SessionId): VisibleJobs /** * Attach an effect-scoped controller that can read and stop jobs. It serves the @@ -435,7 +453,7 @@ abstract onOutput(listener: JobOutputListener): () => void abstract attachController(name: string): () => void ``` -Types: [Agent](core.zh.md) +Types: [SessionId](core.zh.md) Source: [`packages/jobs/jobs/src/index.ts`](../../packages/jobs/jobs/src/index.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index a70f3d6e35..d862a8995b 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 416585106de28296a3105fafbbe6e4edc0d0f16b -tool-catalog.zh.md: 89d2e467863c09b78805fd9db8ec785ad7936a9d +tool-catalog.md: 5bc212bd17256443ba98141ba5952e17abcfd2fe +tool-catalog.zh.md: fbbc265ac30e461a93fcf2a948797dc0c8ca67ad diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 416585106d..5bc212bd17 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -35,7 +35,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | -| `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | +| `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.agents`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 89d2e46786..fbbc265ac3 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -39,7 +39,7 @@ | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | -| `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | +| `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.agents`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - | diff --git a/packages/api/job-controller/src/client/model.ts b/packages/api/job-controller/src/client/model.ts index 2b58208bb0..cdcc884c9d 100644 --- a/packages/api/job-controller/src/client/model.ts +++ b/packages/api/job-controller/src/client/model.ts @@ -8,8 +8,9 @@ import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { JobView } from '@deepseek-ai/dsh-jobs/view' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { JobObserveFrame, JobView } from '../types.ts' +import type { JobObserveFrame } from '../types.ts' /** Bounded per-job render tail, in UTF-16 code units. */ const RENDER_TAIL_LIMIT = 128 * 1024 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index ff56058008..3537921fee 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1038,9 +1038,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Host service backing the generated `ctx.remote.job` namespace.', description: 'Host service backing the generated `ctx.remote.job` namespace.', methods: [ + { + signature: '@Remote({ mode: \'stream\' }) rows(request: JobRowsRequest, signal: AbortSignal): AsyncIterable', + description: 'Stream the jobs one session can see — its own plus every unowned job — as whole-set frames: one on open, then one after each coalesced burst of lifecycle commits. The stream has no natural end; the carrier closes it.', + parameters: [{ name: 'request', description: 'the session whose visible set to mirror.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], + returns: 'the roster frames.', + }, { signature: '@Remote({ mode: \'stream\' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable', - description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.', + description: 'Stream one job\'s retained output from an absolute byte offset, then its terminal projection once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session is the fenced read\'s caller; the registry rejects a job the session cannot see and an unknown job.', parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'anchor, coalesced output frames, and the terminal status.', }, @@ -1049,67 +1055,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'jobs', summary: 'Abstract background job registry.', - description: 'Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected.\n- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle.', + description: 'Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: \'teardown\'`, because a job whose owner is being destroyed has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: \'scope\' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model\'s consuming cursor and observers\' absolute offsets read the same bytes and never disturb each other.\n- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.', methods: [ { - signature: 'abstract start(spec: JobStart): JobId', - description: 'Preflight access, validation, owner cleanup, and implementation-owned admission before starting and atomically registering work. Any preflight rejection leaves no job id or execution resource. A throwing starter leaves nothing registered; after it returns, registration cannot fail. Settlement records the outcome, notifies listeners, and releases waiters.', - parameters: [{ name: 'spec', description: 'job identity, owner, and synchronous starter.' }], + signature: 'abstract readonly events: JobEvents', + description: 'Lifecycle and output events, filtered per subscription.', + parameters: [], + }, + { + signature: 'abstract start(spec: JobSpec): JobId', + description: 'Preflight access, validation, owner cleanup, and implementation-owned admission before starting and atomically registering work. Any preflight rejection leaves no job id or execution resource. A throwing starter leaves nothing registered; after it returns, registration cannot fail.', + parameters: [{ name: 'spec', description: 'job identity, owner, output sources, and synchronous starter.' }], returns: 'the registry-issued `-N` id.', }, { - signature: 'abstract list(caller?: Agent): JobSnapshot[]', - description: 'List caller-owned and unowned jobs in registration order without exposing another session\'s labels.', - parameters: [{ name: 'caller', description: 'reading agent; a non-agent caller sees only unowned jobs.' }], - returns: 'fresh snapshots.', - }, - { - signature: 'abstract get(id: JobId, caller?: Agent): JobSnapshot', - description: 'Return a non-consuming snapshot without changing its read cursor or notice state. Throws for an unknown or foreign job.', - parameters: [{ name: 'id', description: 'job to look up.' }, { name: 'caller', description: 'reading agent checked against the owner.' }], - returns: 'a fresh snapshot.', - }, - { - signature: 'abstract read(id: JobId, caller?: Agent): JobRead', - description: 'Read the next stream delta, or the idempotent final output after settlement. A terminal read marks the job reported. Throws for an unknown or foreign job.', - parameters: [{ name: 'id', description: 'job to read.' }, { name: 'caller', description: 'reading agent checked against the owner.' }], - returns: 'output text and the post-read snapshot.', - }, - { - signature: 'abstract kill(id: JobId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'', - description: 'Request cancellation, then mark the job stopping and reported. A producer throw propagates without changing job state. Throws for an unknown or foreign job.', - parameters: [{ name: 'id', description: 'job to cancel.' }, { name: 'caller', description: 'killing agent checked against the owner.' }, { name: 'reason', description: 'logged reason forwarded to the producer.' }], - returns: '`requested` for live work, otherwise `already-finished`.', - }, - { - signature: 'abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise', - description: 'Wait for settlement or timeout without cancelling the job. Caller abort rejects only while the job is live; after settlement the terminal snapshot wins so a notice suppressed for this waiter is still delivered. Throws for invalid, unknown, or foreign input.', - parameters: [{ name: 'id', description: 'job to wait for.' }, { name: 'timeoutMs', description: 'positive finite wait bound in milliseconds.' }, { name: 'caller', description: 'waiting agent checked against the owner.' }, { name: 'signal', description: 'optional cancellation of the wait itself.' }], - returns: 'snapshot at settlement or timeout.', - }, - { - signature: 'abstract onJobDone(listener: JobDoneListener): () => void', - description: 'Register an effect-scoped completion listener. It receives the settlements of the owners its registering context\'s scope covers; each listener is contained; returned promises are observed but not awaited. No listener runs after service disposal.', - parameters: [{ name: 'listener', description: 'receives each terminal snapshot and its exact owner.' }], - returns: 'disposer that unregisters the listener.', - }, - { - signature: 'abstract onJobsChanged(listener: JobsChangedListener): () => void', - description: 'Register an effect-scoped observer of visible-set changes. It fires after every commit that changes what list returns for that owner — registration, every stopping transition (including the one teardown performs before it awaits a slow producer), settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas.\n\nDelivery is owner-relative on the same terms as onJobDone: an observer registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it.\n\nThis is not a superset of onJobDone: that one delivers the terminal record under first-wins semantics a job controller couples to notice delivery, while this one carries no delivery meaning and marks nothing reported. Listeners are contained and never awaited.', - parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned job changed and every caller\'s set did.' }], - returns: 'disposer that unregisters the listener.', - }, - { - signature: 'abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead', - description: 'Read retained record output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Never marks the job reported. Throws for an unknown or foreign job, a job without a JobStart.record declaration, or a negative or non-integer offset.', - parameters: [{ name: 'id', description: 'job to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }], - returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.', - }, - { - signature: 'abstract onOutput(listener: JobOutputListener): () => void', - description: 'Register an effect-scoped observer of record advancement — one signal per committed append and one at settlement, carrying only the job id. A consumer schedules a readRecord from its own cursor; the registry never pushes payloads. Delivery is owner-relative on the same terms as onJobsChanged. Listeners are contained and never awaited.', - parameters: [{ name: 'listener', description: 'receives the id whose record advanced.' }], - returns: 'disposer that unregisters the listener.', + signature: 'abstract visibleTo(caller?: SessionId): VisibleJobs', + description: 'Bind the caller\'s identity once and return its operations. A caller sees its own jobs and every unowned job; a caller-less view sees unowned jobs only. The view resolves visibility on every call, so it stays valid as jobs come and go.', + parameters: [{ name: 'caller', description: 'the reading session, or undefined for an anonymous caller.' }], + returns: 'the operations available to that caller.', }, { signature: 'abstract attachController(name: string): () => void', @@ -4175,20 +4138,44 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'JobChannel', - declaration: 'export type JobChannel = \'stdout\' | \'stderr\';', + declaration: 'export type JobChannel = \'stdout\' | \'stderr\' | \'log\';', }, { - name: 'JobDoneListener', - declaration: 'export type JobDoneListener = (snapshot: JobSnapshot, owner: Agent | undefined) => void | PromiseLike;', + name: 'JobChunk', + declaration: 'export interface JobChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: JobChannel;\n readonly gapBefore?: true;\n}', + }, + { + name: 'JobEvent', + declaration: 'export type JobEvent = {\n readonly type: \'registered\' | \'progress\' | \'stopping\' | \'removed\';\n readonly job: JobView;\n} | {\n readonly type: \'settled\';\n readonly job: JobView;\n readonly cause: JobSettleCause;\n} | {\n readonly type: \'output\';\n readonly id: JobId;\n readonly owner?: SessionId;\n readonly total: number;\n};', + }, + { + name: 'JobEventFilter', + declaration: 'export type JobEventFilter = {\n readonly owner: SessionId;\n} | {\n readonly owners: \'all\' | \'scope\';\n};', + }, + { + name: 'JobEventListener', + declaration: 'export type JobEventListener = (event: JobEvent) => void;', + }, + { + name: 'JobEvents', + declaration: 'export interface JobEvents {\n subscribe(filter: JobEventFilter, listener: JobEventListener): () => void;\n}', + }, + { + name: 'JobHandle', + declaration: 'export interface JobHandle {\n readonly id: JobId;\n append(text: string, options?: JobAppendOptions): void;\n updateProgress(line: string): void;\n}', }, { name: 'JobHooks', - declaration: 'export interface JobHooks {\n cancel(reason?: string): void;\n done: Promise;\n readOutput?(): string;\n}', + declaration: 'export interface JobHooks {\n cancel(reason?: string): void;\n done: Promise;\n}', }, { name: 'JobId', declaration: 'export type JobId = Branded<\'JobId\'>;', }, + { + name: 'JobKillOptions', + declaration: 'export interface JobKillOptions {\n reason?: string;\n}', + }, { name: 'JobKind', declaration: 'export type JobKind = JobKindMap[keyof JobKindMap];', @@ -4199,55 +4186,55 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'JobObserveFrame', - declaration: 'export type JobObserveFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: JobObserveStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly JobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: JobObserveStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};', + declaration: 'export type JobObserveFrame = {\n readonly type: \'opened\';\n readonly job: JobView;\n readonly from: number;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly JobChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly job: JobView;\n};', }, { name: 'JobObserveRequest', declaration: 'export interface JobObserveRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}', }, - { - name: 'JobObserveStatus', - declaration: 'export type JobObserveStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';', - }, { name: 'JobOutcome', - declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}', + declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n result?: string;\n}', }, { - name: 'JobOutputListener', - declaration: 'export type JobOutputListener = (id: JobId) => void;', + name: 'JobOutputRead', + declaration: 'export interface JobOutputRead {\n chunks: readonly JobChunk[];\n next: number;\n lossy: boolean;\n}', + }, + { + name: 'JobOutputSource', + declaration: 'export interface JobOutputSource {\n channel?: JobChannel;\n read(fromByte: number): JobSourceRead;\n}', }, { name: 'JobRead', - declaration: 'export interface JobRead {\n text: string;\n snapshot: JobSnapshot;\n}', + declaration: 'export interface JobRead {\n chunks: readonly JobChunk[];\n lossy: boolean;\n result?: string;\n job: JobView;\n}', }, { - name: 'JobRecordChunk', - declaration: 'export interface JobRecordChunk {\n at: number;\n text: string;\n channel?: JobChannel;\n gapBefore?: true;\n}', + name: 'JobRowsFrame', + declaration: 'export interface JobRowsFrame {\n readonly type: \'rows\';\n readonly jobs: readonly JobView[];\n}', }, { - name: 'JobRecordRead', - declaration: 'export interface JobRecordRead {\n chunks: readonly JobRecordChunk[];\n next: number;\n lossy: boolean;\n}', + name: 'JobRowsRequest', + declaration: 'export interface JobRowsRequest {\n readonly sessionId: SessionId;\n}', }, { - name: 'JobsChangedListener', - declaration: 'export type JobsChangedListener = (owner: Agent | undefined) => void;', + name: 'JobSettleCause', + declaration: 'export type JobSettleCause = \'producer\' | \'kill\' | \'teardown\';', }, { - name: 'JobSnapshot', - declaration: 'export interface JobSnapshot {\n id: JobId;\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: JobStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n outputTotal?: number;\n outputEarliest?: number;\n}', + name: 'JobSourceRead', + declaration: 'export interface JobSourceRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n}', }, { - name: 'JobStart', - declaration: 'export type JobStart = PlainJobStart | RecordingJobStart;', + name: 'JobSpec', + declaration: 'export interface JobSpec {\n kind: JobKind;\n label: string;\n owner?: SessionId;\n outputLimitBytes?: number;\n output?: readonly JobOutputSource[];\n run(job: JobHandle): JobHooks;\n}', }, { name: 'JobStatus', declaration: 'export type JobStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';', }, { - name: 'JobWireChunk', - declaration: 'export interface JobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', + name: 'JobView', + declaration: 'export interface JobView {\n readonly id: JobId;\n readonly kind: JobKind;\n readonly label: string;\n readonly owner?: SessionId;\n readonly outputLimitBytes?: number;\n readonly status: JobStatus;\n readonly progress?: string;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly output: {\n readonly total: number;\n readonly earliest: number;\n };\n}', }, { name: 'JsonSchemaNode', @@ -4537,10 +4524,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PermissionSelect', declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}', }, - { - name: 'PlainJobStart', - declaration: 'export interface PlainJobStart extends JobStartBase {\n record?: undefined;\n run(job: RunningJob): JobHooks;\n}', - }, { name: 'PostToolDecision', declaration: 'export type PostToolDecision = {\n kind: \'accept\';\n content?: ContentBlock[];\n value?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'accept\';\n value: JsonValue;\n content?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'block\';\n feedback: ContentBlock[];\n additionalContexts?: UserMessage[];\n};', @@ -4661,14 +4644,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ReasoningEffortId', declaration: 'export type ReasoningEffortId = Branded<\'ReasoningEffortId\'>;', }, - { - name: 'RecordingJob', - declaration: 'export interface RecordingJob extends RunningJob {\n append(text: string, options?: JobAppendOptions): void;\n}', - }, - { - name: 'RecordingJobStart', - declaration: 'export interface RecordingJobStart extends JobStartBase {\n record: true;\n run(job: RecordingJob): JobHooks;\n}', - }, { name: 'RedactedSecret', declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}', @@ -4749,10 +4724,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RunnerFailureRule', declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}', }, - { - name: 'RunningJob', - declaration: 'export interface RunningJob {\n readonly id: JobId;\n updateDetail(detail: string): void;\n}', - }, { name: 'SandboxEnforcement', declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';', @@ -4863,11 +4834,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionControlBaseline', - declaration: 'export interface SessionControlBaseline {\n readonly queues: Readonly>;\n readonly jobs: Readonly>;\n readonly projections: Readonly>;\n}', + declaration: 'export interface SessionControlBaseline {\n readonly queues: Readonly>;\n readonly projections: Readonly>;\n}', }, { name: 'SessionControlFrame', - declaration: 'export type SessionControlFrame = {\n readonly type: \'baseline\';\n readonly value: SessionControlBaseline;\n} | {\n readonly type: \'queue\';\n readonly sessionId: SessionId;\n readonly items: readonly SessionQueuedItem[];\n} | {\n readonly type: \'jobs\';\n readonly sessionId: SessionId;\n readonly jobs: readonly SessionJob[];\n} | ({\n readonly type: \'projection\';\n} & SessionProjectionUpdate);', + declaration: 'export type SessionControlFrame = {\n readonly type: \'baseline\';\n readonly value: SessionControlBaseline;\n} | {\n readonly type: \'queue\';\n readonly sessionId: SessionId;\n readonly items: readonly SessionQueuedItem[];\n} | ({\n readonly type: \'projection\';\n} & SessionProjectionUpdate);', }, { name: 'SessionCreateRequest', @@ -4997,10 +4968,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionInspection', declaration: 'export interface SessionInspection extends SessionStorageMetadata {\n readonly events: readonly SessionEvent[];\n}', }, - { - name: 'SessionJob', - declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly record?: true;\n}', - }, { name: 'SessionLineageNode', declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}', @@ -6009,6 +5976,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'VerifiedWebhookDelivery', declaration: 'export interface VerifiedWebhookDelivery {\n readonly kind: K;\n readonly source: WebhookSourceId;\n readonly deliveryId: WebhookDeliveryId;\n readonly event: WebhookEventOf;\n readonly receivedAt: number;\n}', }, + { + name: 'VisibleJobs', + declaration: 'export interface VisibleJobs {\n list(): JobView[];\n get(id: JobId): JobView;\n read(id: JobId): JobRead;\n readAt(id: JobId, from: number): JobOutputRead;\n kill(id: JobId, options?: JobKillOptions): \'requested\' | \'already-finished\';\n wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise;\n}', + }, { name: 'WebBootBatch', declaration: 'export interface WebBootBatch {\n phase: WebBootBatchPhase;\n url: string;\n rev: string;\n entries: string[];\n}', diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml index 9c5a47c63b..1405d2ee15 100644 --- a/packages/jobs/jobs-local/README.i18n.yaml +++ b/packages/jobs/jobs-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs-local/README.md -README.md: 07772f3a8de79f4e812e5217843eb770925050fb -README.zh.md: bf728c5b935941ee3bb783866a9dbf09f7865d76 +README.md: 58a35e813af03a00cf978d001a12f26bbb848ff4 +README.zh.md: 486a9cef3813d4d07c328339ee179e08e97c98b3 diff --git a/packages/jobs/jobs-local/README.md b/packages/jobs/jobs-local/README.md index 07772f3a8d..58a35e813a 100644 --- a/packages/jobs/jobs-local/README.md +++ b/packages/jobs/jobs-local/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh snapshots, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once, and a job that declares a record keeps a bounded in-memory output ring (256 KiB live, trimmed to 16 KiB at settlement by default) that observers read at absolute offsets; jobs die with the harness process and are not durable across restarts. +`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh projections, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once, and every job keeps a bounded in-memory output ring (256 KiB live, trimmed to 16 KiB at settlement) that the registry fills from the producer's pull sources at `pumpPollMs` (default 150 ms) and from pushed appends. ## Table of Contents @@ -33,7 +33,7 @@ Choose it when jobs should live in the harness process and die with it. Avoid it ### Minimal configuration -Loading the plugin registers `ctx.jobs`; `maxConcurrentJobsPerOwner` is optional and defaults to `10`. +Loading the plugin registers `ctx.jobs`; every field is optional. ```yaml - name: '@deepseek-ai/dsh-jobs-local' @@ -42,8 +42,9 @@ Loading the plugin registers `ctx.jobs`; `maxConcurrentJobsPerOwner` is optional | Field | Default | Meaning | |---|---|---| | `maxConcurrentJobsPerOwner` | `10` | Maximum `running` plus `stopping` jobs per exact owner, or in the shared unowned bucket | -| `retainBytes` | `262144` | Live record retention per job, in UTF-8 bytes | -| `settledRetainBytes` | `16384` | Record retention kept after a job settles, in UTF-8 bytes | +| `retainBytes` | `262144` | Live ring retention per job, in UTF-8 bytes | +| `settledRetainBytes` | `16384` | Ring retention kept after a job settles, in UTF-8 bytes | +| `pumpPollMs` | `150` | Poll interval for a job's pull sources, in milliseconds | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-jobs-local) is the exhaustive source for the accepted fields. @@ -71,11 +72,11 @@ This section explains the design decisions behind the registry and points at the ### Design philosophy -- **In-memory records, fresh snapshots.** `LocalJobRegistry` keeps one `TrackedTask` per job — lifecycle state plus the optional record ring — and projects a new read-only snapshot or chunk copy per call; callers never receive live state. -- **Retention is bounded per record.** Appends past the live cap drop the oldest retained chunks (a single oversized chunk keeps its UTF-8-safe tail); a reader below the retained window gets a lossy read, never an error, and settlement trims to the settled cap. -- **Owner-relative layers, one process-wide registry.** Controllers, completion listeners, and change observers are filed into the scope that registered them (`ScopedLayers`), and reads union the global layer with the owner's scope chain — so one preset's job controls never hold `start()` open for an agent whose own composition loads none, and a settlement reaches only the listeners its owner's composition registered. +- **In-memory records, fresh projections.** `LocalJobRegistry` keeps one `TrackedJob` per job — lifecycle state, the output ring, and the model cursor — and projects a new read-only view or chunk copy per call; callers never receive live state. +- **Retention is bounded per ring.** Appends past the live cap drop the oldest retained chunks (a single oversized chunk keeps its UTF-8-safe tail); a reader below the retained window gets a lossy read, never an error, and settlement trims to the settled cap. +- **Owner-relative layers, one process-wide registry.** Controllers and `{ owners: 'scope' }` subscriptions are filed into the scope that registered them (`ScopedLayers`), and reads union the global layer with the owner's scope chain — so one preset's job controls never hold `start()` open for an agent whose own composition loads none, and a scoped settlement reaches only the listeners its owner's composition registered. - **Preflight before start.** `start()` checks controller service, spec validity, live ownership, and capacity before invoking the producer, so a rejection leaves no job id or execution resource; registration commits without a later failable step. -- **First-wins settlement, completion last.** The earliest terminal outcome records once, releases waiters, and notifies listeners once with per-listener containment; completion is announced after the record is committed and the visible-set change published, because a reporter may open a model turn synchronously. +- **First-wins settlement, event last.** The earliest terminal outcome records once, waits for the pump's final drain, trims the ring, releases waiters, and then delivers one `settled` event with per-listener containment, followed by the ring's final `output` signal. - **Teardown never deadlocks.** A throwing cancel force-fails the record and reports a possible orphan instead of stalling disposal. ### Source map @@ -83,19 +84,22 @@ This section explains the design decisions behind the registry and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, `LocalJobRegistry`, admission, lifecycle, teardown | +| [`src/events.ts`](src/events.ts) | Scope-layered event routing: `{ owner }`, `{ owners: 'all' }`, and `{ owners: 'scope' }` subscriptions | +| [`src/ring.ts`](src/ring.ts) | The bounded per-job output ring: append, retention trim, offset reads | +| [`src/pump.ts`](src/pump.ts) | The registry-owned pull pump: one timer per job, final drain before settlement | | — | No runtime invariant companion is published; `@deepseek-ai/dsh-jobs/invariant` owns per-snapshot identity, status, timestamp, and owner checks. This provider's admission decision uses private configuration and must fail before a backend starter runs; `LocalJobRegistry.start()` enforces it synchronously for current producers. Repeating an aggregate after publication would expose private configuration solely to this companion and would not verify the fail-closed pre-start guarantee. | ### Scope layers -`attachController`, `onJobDone`, `onJobsChanged`, and `onOutput` register into the calling context's scope layer. The controller question (`servesOwner`) and listener delivery (`listenersFor`, `changedFor`, `outputFor`) walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable. +`attachController` and `{ owners: 'scope' }` subscriptions register into the calling context's scope layer; `{ owner }` and `{ owners: 'all' }` subscriptions are unscoped. The controller question (`servesOwner`) and scoped delivery walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable. ### Admission and settlement -`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` marks a job reported when waiters are pending, trims a declared record to the settled cap, resolves every waiter, records the terminal snapshot, announces the visible-set change and the record's final output signal, then notifies completion listeners. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices; a teardown cancel marks it for the same reason — nothing will read a notice addressed to an owner being destroyed. +`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` records the terminal outcome once (merging a recorded kill reason into a `killed` detail), clears the progress line, trims the ring to the settled cap, resolves every waiter, then emits `settled` with its cause and the ring's final `output` signal. The cause is `kill` after `VisibleJobs.kill`, `teardown` after an owner or service cancel, and `producer` otherwise; `dsh-tool-jobs` uses it to skip notices nobody can read. ### Teardown -Owner disposal (`disposeOwned`) cancels the owner's jobs, awaits their settlement, removes their records, and announces the removal — the one visible-set change no per-job record carries. Service disposal (`disposeAll`) closes listeners, cancels all live jobs, awaits settlement, clears the store, announces the emptying to the distinct owners, then detaches the cross-fiber owner-cleanup effects. +Owner disposal (`disposeOwned`) cancels the owner's jobs, awaits their settlement, removes their records, and announces each removal — the one visible-set change no per-job record carries. Service disposal (`disposeAll`) cancels all live jobs, awaits settlement, removes every record (announcing each removal, so a subscriber mounted outside the service drops its rows), then detaches the cross-fiber owner-cleanup effects. @@ -106,7 +110,7 @@ Owner disposal (`disposeOwned`) cancels the owner's jobs, awaits their settlemen Read these pages when the package-level contract is not enough. They move from the registry contract to the model-facing controls and the design records. -- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, snapshot fields, and `ctx.jobs` cordis surface. +- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, projection fields, and `ctx.jobs` cordis surface. - [jobs group map](../README.md) — the sibling group page and its package table. - [Registry contract](../jobs/README.md) — the abstract `ctx.jobs` service this package implements. - [Model-facing job controls](../tool-jobs/README.md) — the `job_output`, `job_list`, and `job_kill` tools and completion notices. diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md index bf728c5b93..486a9cef38 100644 --- a/packages/jobs/jobs-local/README.zh.md +++ b/packages/jobs/jobs-local/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新快照,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量,声明了 record 的任务另持有一个有界的内存输出环(默认运行期 256 KiB、结算时裁剪到 16 KiB),供观察者按绝对偏移读取;任务会随 harness 进程终止而消失,无法跨重启持久。 +`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新投影,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量;每个 job 保有一个有界的内存输出环(运行期 256 KiB,结算时裁剪到 16 KiB),注册表按 `pumpPollMs`(默认 150 毫秒)从生产方的拉取源以及推送的追加填充它。 ## 目录 @@ -33,7 +33,7 @@ kind: "package-reference" ### 最小配置 -加载插件即注册 `ctx.jobs`;`maxConcurrentJobsPerOwner` 可选,默认为 `10`。 +加载插件即注册 `ctx.jobs`;每个字段都是可选的。 ```yaml - name: '@deepseek-ai/dsh-jobs-local' @@ -42,8 +42,9 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `maxConcurrentJobsPerOwner` | `10` | 每个精确所有者,或共享的无主桶中,`running` 加 `stopping` 任务的最大数量 | -| `retainBytes` | `262144` | 每个任务 record 的运行期保留量,UTF-8 字节 | -| `settledRetainBytes` | `16384` | 任务结算后保留的 record 量,UTF-8 字节 | +| `retainBytes` | `262144` | 每个任务输出环的运行期保留量,UTF-8 字节 | +| `settledRetainBytes` | `16384` | 任务结算后保留的环容量,UTF-8 字节 | +| `pumpPollMs` | `150` | 任务拉取源的轮询间隔,毫秒 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-jobs-local)是每个受支持字段的穷尽式真源。 @@ -71,11 +72,11 @@ kind: "package-reference" ### 设计理念 -- **内存记录、全新快照。** `LocalJobRegistry` 为每个任务保存一份 `TrackedTask`——生命周期状态加可选的 record 环——每次调用都投影新的只读快照或块副本;调用方永远拿不到实时状态。 -- **record 保留量有界。** 追加超出运行期上限时丢弃最老的保留块(单个超限块保留其 UTF-8 安全尾部);低于保留窗口的读取得到 lossy 结果而非错误,结算时裁剪到结算上限。 -- **按所有者分层,一个进程级注册表。** 控制器、完成监听器与变更观察者归档到注册方所在的 scope(`ScopedLayers`),读取把全局层与所有者的 scope 链求并集——因此某个 preset 的任务控制绝不会为自身组合未加载任何控制器的 agent 保持 `start()` 可用,一次结算也只会抵达其所有者所属组合注册的监听器。 +- **内存记录、全新投影。** `LocalJobRegistry` 为每个任务保存一份 `TrackedJob`——生命周期状态、输出环与模型游标——每次调用都投影新的只读视图或块副本;调用方永远拿不到实时状态。 +- **环的保留量有界。** 追加超出运行期上限时丢弃最老的保留块(单个超限块保留其 UTF-8 安全尾部);低于保留窗口的读取得到 lossy 结果而非错误,结算时裁剪到结算上限。 +- **按所有者分层,一个进程级注册表。** 控制器与 `{ owners: 'scope' }` 订阅归档到注册方所在的 scope(`ScopedLayers`),读取把全局层与所有者的 scope 链求并集——因此某个 preset 的任务控制绝不会为自身组合未加载任何控制器的 agent 保持 `start()` 可用,一次带 scope 的结算也只会抵达其所有者所属组合注册的监听器。 - **启动前先预检。** `start()` 在调用生产方之前检查控制器服务、spec 有效性、仍存活的所有权与容量,因此拒绝不会留下 job id 或执行资源;注册一旦提交,后续不再有可失败步骤。 -- **结算首次优先,完成最后。** 最早的终止结果只记录一次,释放等待方,并只通知监听器一次,各监听器故障单独隔离;完成在记录提交且可见集变更发布之后才宣布,因为报告方可能同步开启一个模型轮次。 +- **结算首次优先,事件最后。** 最早的终止结果只记录一次,等待泵的最后一次排干,裁剪环,释放等待方,然后投递一次带逐监听器隔离的 `settled` 事件,再跟上环的最终 `output` 信号。 - **销毁永不死锁。** 抛出的取消会强制失败记录并报告可能的孤立工作,而不是让释放停滞。 ### 源码地图 @@ -83,19 +84,22 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、`LocalJobRegistry`、准入、生命周期、销毁 | +| [`src/events.ts`](src/events.ts) | 按 scope 分层的事件路由:`{ owner }`、`{ owners: 'all' }` 与 `{ owners: 'scope' }` 订阅 | +| [`src/ring.ts`](src/ring.ts) | 每个任务的有界输出环:追加、保留裁剪、按偏移读取 | +| [`src/pump.ts`](src/pump.ts) | 注册表拥有的拉取泵:每个任务一个定时器,结算前最后一次排干 | | — | 不发布运行时不变式伴生入口;快照检查位于 `dsh-jobs/invariant`。 | ### scope 分层 -`attachController`、`onJobDone`、`onJobsChanged` 与 `onOutput` 注册到调用上下文所在的 scope 层。控制器问题(`servesOwner`)与监听器投递(`listenersFor`、`changedFor`、`outputFor`)走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。 +`attachController` 与 `{ owners: 'scope' }` 订阅注册到调用上下文所在的 scope 层;`{ owner }` 与 `{ owners: 'all' }` 订阅不带 scope。控制器问题(`servesOwner`)与带 scope 的投递走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。 ### 准入与结算 -`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 在存在挂起等待方时把任务标为已报告,把已声明的 record 裁剪到结算保留量,解析每个等待方,记录终止快照,宣布可见集变更与 record 的最终输出信号,然后通知完成监听器。挂起的等待会在监听器运行前把任务标为已报告,因此完成报告方不会重复通知;销毁时的取消出于同样理由标记——面向正在被销毁的所有者的通知不会有人读到。 +`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 只记录一次终止结果(把记录下来的 kill 原因合并进 `killed` 的 detail),清除进度行,把环裁剪到结算保留量,解析每个等待方,然后发出带原因的 `settled` 与环的最终 `output` 信号。原因在 `VisibleJobs.kill` 之后为 `kill`,在所有者或服务取消之后为 `teardown`,否则为 `producer`;`dsh-tool-jobs` 据此跳过没人能读的通知。 ### 销毁 -所有者释放(`disposeOwned`)会取消该所有者的任务、等待其结算、移除其记录,并宣布移除——这是任何逐任务记录都无法表达的可见集变更。服务释放(`disposeAll`)会关闭监听器、取消所有存活任务、等待结算、清空存储、向不同的所有者宣布清空,然后分离跨 fiber 的所有者清理 effect。 +所有者释放(`disposeOwned`)会取消该所有者的任务、等待其结算、移除其记录,并逐条宣布移除——这是任何逐任务记录都无法表达的可见集变更。服务释放(`disposeAll`)会取消所有存活任务、等待结算、移除每条记录(逐条宣布移除,让挂载在服务之外的订阅者丢掉它的行),然后分离跨 fiber 的所有者清理 effect。 @@ -106,7 +110,7 @@ kind: "package-reference" 当包级约定不够用时阅读以下页面。它们从注册表约定逐步进入模型侧控制与设计记录。 -- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、快照字段与 `ctx.jobs` 的 cordis 接口面。 +- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、投影字段与 `ctx.jobs` 的 cordis 接口面。 - [jobs 组映射](../README.zh.md)——同级组页面及其包表格。 - [注册表约定](../jobs/README.zh.md)——本包实现的抽象 `ctx.jobs` 服务。 - [模型侧任务控制](../tool-jobs/README.zh.md)——`job_output`、`job_list` 与 `job_kill` 工具及完成通知。 diff --git a/packages/jobs/jobs-local/src/events.ts b/packages/jobs/jobs-local/src/events.ts index 333d5db5fc..8c13e4dd9c 100644 --- a/packages/jobs/jobs-local/src/events.ts +++ b/packages/jobs/jobs-local/src/events.ts @@ -23,7 +23,9 @@ interface Subscription { * by a name a second registrant could shadow. */ export class JobLayer implements ScopeLayer { + /** Tokens of the job controllers attached from this scope. */ readonly controllers = new AnonymousEntries() + /** The `{ owners: 'scope' }` subscriptions registered from this scope. */ readonly scoped = new AnonymousEntries() isEmpty(): boolean { diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index 54d4ecc8e9..ca8c2269ef 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md -README.md: 5d4e10de9fa852dc4a38c521321bae53c744909b -README.zh.md: b584191a6b94e79ba8908875e7a0706a525edf4f +README.md: 4a82606f7988827ce8279c649f0fe1caeb34745e +README.zh.md: 56e4711acd550b7cb1278739cb60415c9e7ad065 diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index 5d4e10de9f..4a82606f79 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -1,5 +1,5 @@ --- -description: "The background-job registry contract for users and maintainers composing, implementing, or debugging background work: ids, ownership, lifecycle, and completion listeners." +description: "The background-job registry contract for users and maintainers composing, implementing, or debugging background work: ids, ownership, lifecycle, the output ring, and the event stream." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. A producer can also declare a per-job observation record: an append-only bounded output stream that any number of observers (the Web client) read at absolute byte offsets without touching the model-facing consuming cursor. This package ships the contract only: the process-local registry lives in `dsh-jobs-local`, and the model-facing controls and completion notices live in `dsh-tool-jobs`. Load an implementation to get background jobs; without one, `ctx.jobs` does not exist and `start()` cannot run. +`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. Every job owns one bounded output ring: the registry pumps the producer's pull sources into it and accepts pushed appends, the model consumes it through a registry-kept cursor, and any number of observers (the Web client) read it at absolute byte offsets without touching that cursor. ## Table of Contents @@ -29,9 +29,9 @@ Use this package when you are composing a background-job capability or writing a ### What a background job gives you -A producer registers work with a kind and a one-line label; the registry returns a `-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh snapshot of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the owning agent is notified through the completion listener that `dsh-tool-jobs` turns into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing output read or completion notice stays bounded. +A producer registers work with a kind and a one-line label; the registry returns a `-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh projection of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the registry's event stream announces it and `dsh-tool-jobs` turns the settlement into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing read or notice stays bounded. -A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RecordingJob` face its starter receives — a start without the declaration receives the plain `RunningJob` face, which has no `append`: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state. +A producer streams output by naming pull sources on its spec — non-consuming offset readers the registry pumps at its own cadence — or by pushing chunks through the `JobHandle` its starter receives; both land in the job's bounded ring, where `stdout` and `stderr` chunks reach the model and `log` chunks reach observers only. Observers read retained chunks at absolute byte offsets and are signaled on advancement; the settlement that ends the job also ends the stream, and `updateProgress` publishes a live progress line into every projection until then. Observation is invisible to the model: `readAt` consumes nothing and never touches notice state. ### The ownership boundary @@ -69,7 +69,7 @@ This section explains the design decisions behind the contract and points at the - **Contract and implementation are separate packages.** `JobRegistry` is an abstract Cordis service; loading the class directly throws, so a misconfigured composition fails at load instead of registering an empty `ctx.jobs`. - **One registry per process, owner-relative answers.** One instance serves every composition in the process, so registrations and deliveries are relative to the registering scope: a controller or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. - **Access is fenced by the owner's session id.** Ids are predictable, so authorization — not secrecy — is the boundary. -- **Settlement is first-wins, and completion is announced last.** One terminal record, released waiters, and one round of contained listener notification; completion is announced after the record is committed and every other observer has seen it, because a reporter may open a model turn synchronously. +- **Settlement is first-wins, and its event follows every released waiter.** One terminal record, released waiters, then one round of contained event delivery; a consumer that claims a settlement while waiting therefore always claims before the event, so `dsh-tool-jobs` never announces a completion the model already collected. - **Registrations outlive producer and controller fibers.** Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. ### Source map @@ -77,14 +77,14 @@ This section explains the design decisions behind the contract and points at the | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract | -| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `RecordingJob`, `JobSnapshot`, listener types | +| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobSpec`, `JobHandle`, `JobHooks`, `JobOutcome`, `VisibleJobs`, `JobEvent`, and the read results | +| [`src/view.ts`](src/view.ts) | Client-safe leaf: `JobView`, `JobChunk`, `JobStatus`, and the merge-extensible `JobKindMap` | | [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency | -| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`: poll pump copying producer offset-readers into a job record | -| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, owner, and record-offset fields | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates every projection the registry hands out — identity, status, timestamps, owner, and ring offsets | ### Service operations -Every operation is a thin projection over the registered jobs: `get` and `list` return non-consuming snapshots, `read` advances the single stream cursor, `readRecord` reads retained record chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; listeners observe terminal records, visible-set changes, and record advancement at owner granularity, and `attachController` scopes controller availability to its effect lifetime. Exact signatures and behavior live in the JSDoc on [`src/index.ts`](src/index.ts) and the generated [`ctx.jobs` cordis surface](../../../docs/subsystems/jobs.md). +Every operation is a thin projection over the registered jobs, bound to one caller by `visibleTo`: `list` and `get` return fresh projections, `read` advances the model's cursor and hands out the producer's result once after settlement, `readAt` reads retained chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status and records the reason for the terminal `detail`, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; `events.subscribe` delivers registration, progress, stopping, settlement, removal, and output commits at owner, scope, or process granularity. @@ -95,12 +95,13 @@ Every operation is a thin projection over the registered jobs: `get` and `list` Read these pages when the package-level contract is not enough. They move from the job types to the shipped implementation, the model-facing controls, and the design records. -- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, snapshot fields, and `ctx.jobs` cordis surface. +- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, projection fields, and `ctx.jobs` cordis surface. - [jobs group map](../README.md) — the sibling group page and its package table. - [Process-local registry](../jobs-local/README.md) — the shipped implementation that runs jobs in this process. - [Model-facing job controls](../tool-jobs/README.md) — the `job_output`, `job_list`, and `job_kill` tools and completion notices. - [Generic long-running tool runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) — the design behind the background-job runtime. - [job-registry seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md) — the owner-fenced registry contract and its rationale. +- [Jobs seam consolidation Agent Note](../../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md) — one output ring, one projection, one event stream. ----- @@ -120,8 +121,8 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the contract is a poor fit. They are current package constraints, not a task backlog. -- **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam. -- **Stream output has one consuming cursor** — the model owns it; independent observers read the job's declared record through the non-consuming `readRecord` instead. +- **The contract is in-process** — `JobSpec.run()` passes callbacks and the registry resolves the live `Agent` behind the owner session; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam. +- **The model's cursor is the only consuming read** — independent observers use the non-consuming `readAt` and never move it. - **Foreground work cannot be promoted** — producers choose foreground or background before starting. diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index b584191a6b..56e4711acd 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -1,5 +1,5 @@ --- -description: "后台任务注册表约定,供组合、实现或排查后台工作的用户与维护者阅读:id、归属、生命周期与完成监听器。" +description: "后台任务注册表约定,供组合、实现或排查后台工作的用户与维护者阅读:id、归属、生命周期、输出环与事件流。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。生产方还可以声明按 job 划分的观测 record:一条追加式的有界输出流,任意数量的观察者(Web 客户端)按绝对字节偏移读取,不触碰模型侧的消耗型游标。本包只提供约定:进程本地注册表位于 `dsh-jobs-local`,模型侧控制与完成通知位于 `dsh-tool-jobs`。加载一个实现才能获得后台任务;没有实现时 `ctx.jobs` 不存在,`start()` 无法运行。 +`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。每个 job 拥有一个有界的输出环:注册表把生产方的拉取源泵入其中并接受推送的追加,模型通过注册表保管的游标消耗它,任意数量的观察者(Web 客户端)按绝对字节偏移读取它而不触碰该游标。 ## 目录 @@ -29,9 +29,9 @@ kind: "package-reference" ### 后台任务提供什么 -生产方以 kind 和一行标签注册工作;注册表返回 `-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新快照,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,拥有它的 agent 会通过 `dsh-tool-jobs` 转成会话内通知的完成监听器得到通知,因此无需轮询。生产方还可以附加可选的字节上限,让每次完整的模型侧输出读取或完成通知保持有界。 +生产方以 kind 和一行标签注册工作;注册表返回 `-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新投影,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,注册表的事件流宣布它,`dsh-tool-jobs` 把结算变成会话内通知,因此无需轮询。生产方可以附加一个可选的字节上限,让每次完整的模型侧读取或通知保持有界。 -声明 `record: true` 的生产方还会通过 starter 收到的 `RecordingJob` 面(未声明的 start 收到的是没有 `append` 的普通 `RunningJob` 面)把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。 +生产方通过在 spec 上声明拉取源——由注册表按自己的节奏泵送的非消耗偏移读取器——或经 starter 收到的 `JobHandle` 推送块来流式输出;二者都落入该 job 的有界环,其中 `stdout` 与 `stderr` 块到达模型,`log` 块只到达观察者。观察者按绝对字节偏移读取保留块并在推进时收到信号;结束 job 的结算同时封流,在此之前 `updateProgress` 把实时进度行发布进每个投影。观测对模型不可见:`readAt` 不消耗任何东西,也从不触碰通知状态。 ### 归属边界 @@ -69,7 +69,7 @@ kind: "package-reference" - **约定与实现分属不同包。** `JobRegistry` 是抽象 Cordis 服务;直接加载该类会抛出异常,因此错误配置的组合会在加载时失败,而不是注册一个空的 `ctx.jobs`。 - **每进程一个注册表,按所有者给出答案。** 一个实例服务进程内的每套组合,因此注册与投递都相对注册方所在 scope:从不带 scope 的上下文注册的控制器或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,恰好服务于该组合下组合出的 agent。 - **访问以所有者的会话 id 为界。** id 可预测,因此是授权——而非保密——构成边界。 -- **结算首次优先,完成最后宣布。** 一条终止记录、释放的等待方,以及一轮受到隔离的监听器通知;完成在记录提交且该结算的所有其他观察者都已看到之后才宣布,因为报告方可能同步开启一个模型轮次。 +- **结算首次优先,其事件跟在每个被释放的等待方之后。** 一条终止记录、释放的等待方,然后是一轮受到隔离的事件投递;在等待中认领结算的消费方因此总是在事件之前认领,`dsh-tool-jobs` 永远不会播报模型已经收走的完成。 - **注册的存续期长于生产方与控制器 fiber。** 所有者与服务释放会取消正在运行的工作并等待守约的生产方;抛出异常的销毁取消只强制失败记录。 ### 源码地图 @@ -77,14 +77,14 @@ kind: "package-reference" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 | -| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`RecordingJob`、`JobSnapshot`、监听器类型 | +| [`src/types.ts`](src/types.ts) | 共享词汇:`JobSpec`、`JobHandle`、`JobHooks`、`JobOutcome`、`VisibleJobs`、`JobEvent` 与读取结果 | +| [`src/view.ts`](src/view.ts) | 客户端安全叶子:`JobView`、`JobChunk`、`JobStatus` 与可合并扩展的 `JobKindMap` | | [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 | -| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`:把生产方偏移读取器复制进 job record 的轮询泵 | -| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳、所有者与 record 偏移字段 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验注册表交出的每个投影——标识、状态、时间戳、所有者与环偏移 | ### 服务操作 -每个操作都是已注册任务之上的薄投影:`get` 与 `list` 返回非消费式快照,`read` 推进唯一的流游标,`readRecord` 按绝对偏移读取保留的 record 块且不消耗任何东西,`kill` 在改变状态前调用生产方取消,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;监听器按所有者粒度观察终止记录、可见集变化与 record 推进,`attachController` 把控制器可用性限定在其 effect 生命周期内。确切签名与行为见 [`src/index.ts`](src/index.ts) 的 JSDoc 与生成的 [`ctx.jobs` cordis 接口面](../../../docs/subsystems/jobs.zh.md)。 +每个操作都是已注册任务之上的薄投影,由 `visibleTo` 绑定到一个调用方:`list` 与 `get` 返回全新投影,`read` 推进模型游标并在结算后把生产方的 result 交出一次,`readAt` 按绝对偏移读取保留块且不消耗任何东西,`kill` 在改变状态前调用生产方取消并为终态 `detail` 记录原因,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;`events.subscribe` 按所有者、scope 或进程粒度投递注册、进度、停止中、结算、移除与输出提交。 @@ -95,12 +95,13 @@ kind: "package-reference" 当包级约定不够用时阅读以下页面。它们从任务类型逐步进入随附实现、模型侧控制与设计记录。 -- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、快照字段与 `ctx.jobs` 的 cordis 接口面。 +- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、投影字段与 `ctx.jobs` 的 cordis 接口面。 - [jobs 组映射](../README.zh.md)——同级组页面及其包表格。 - [进程本地注册表](../jobs-local/README.zh.md)——在本进程中运行任务的随附实现。 - [模型侧任务控制](../tool-jobs/README.zh.md)——`job_output`、`job_list` 与 `job_kill` 工具及完成通知。 - [通用长时间运行工具运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)——后台任务运行时背后的设计。 - [任务注册表 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md)——按所有者隔离的注册表约定及其理由。 +- [jobs seam 收敛 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md)——一个输出环、一个投影、一条事件流。 ----- @@ -120,8 +121,8 @@ kind: "package-reference" 这些限制说明约定何时不合适。它们是当前包约束,不是任务积压。 -- **约定是进程内的**——`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 -- **流输出只有一个消费游标**——它属于模型;独立观察者改用非消耗的 `readRecord` 读取 job 声明的 record。 +- **约定是进程内的**——`JobSpec.run()` 传入回调,注册表解析拥有者会话背后的活体 `Agent`;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 +- **模型游标是唯一的消耗式读取**——独立观察者使用非消耗的 `readAt`,从不移动它。 - **前台工作无法转为后台**——生产方在启动前选择前台或后台。 diff --git a/packages/jobs/tool-jobs/README.i18n.yaml b/packages/jobs/tool-jobs/README.i18n.yaml index ca617bc8fb..df53b29100 100644 --- a/packages/jobs/tool-jobs/README.i18n.yaml +++ b/packages/jobs/tool-jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/tool-jobs/README.md -README.md: bb9fea89b4aabe38296c01c910f36e99244680db -README.zh.md: 9820b1536f7f5c8860e7f67b1eee956e6d240462 +README.md: a596aad916f37a0a5778958d7b4cae966221ad3c +README.zh.md: 72fbe6c2d287649337ee41681b7b05c17ffb377a diff --git a/packages/jobs/tool-jobs/README.md b/packages/jobs/tool-jobs/README.md index bb9fea89b4..a596aad916 100644 --- a/packages/jobs/tool-jobs/README.md +++ b/packages/jobs/tool-jobs/README.md @@ -25,19 +25,19 @@ English | [中文](README.zh.md) ## Use this package -Load this plugin in any composition where the agent should start, observe, and stop background jobs: it registers the three tools, attaches the controller producers need, and delivers completion notices. It requires the `ctx.tools`, `ctx.jobs`, and `ctx.systemPrompt` services from the composed harness. +Load this plugin in any composition where the agent should start, observe, and stop background jobs: it registers the three tools, attaches the controller producers need, and delivers completion notices. It requires the `ctx.tools`, `ctx.jobs`, `ctx.agents`, and `ctx.systemPrompt` services from the composed harness. ### The three tools -- `job_output(job_id, wait?, timeout_ms?)` — Read a job's output. Stream jobs return only the output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap and leaves a still-running job alive on timeout. +- `job_output(job_id, wait?, timeout_ms?)` — Read a job's output. Stream jobs return only the output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap and leaves a still-running job alive on timeout. A read renders stdout first and stderr in one `[stderr]` section, notes output that left memory before the read, and carries a job's result (a subagent's answer) exactly once, on the first read after settlement. - `job_list()` — List your background jobs with their ids, kinds, and statuses, one per line: ` [] —