@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +21 -13
- package/README.zh.md +21 -13
- package/lib/client.js +1566 -929
- package/lib/client.js.map +1 -1
- package/lib/index.js +934 -176
- package/lib/types/client/board/TaskForm.d.ts +19 -1
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/locales.d.ts +38 -2
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +42 -2
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +26 -2
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +65 -4
- package/lib/types/host-runner.d.ts +76 -3
- package/lib/types/host-service.d.ts +77 -5
- package/lib/types/index.d.ts +11 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +9 -3
- package/package.json +26 -23
- package/src/client/board/LinkSubtaskModal.tsx +21 -11
- package/src/client/board/NewTaskModal.tsx +271 -150
- package/src/client/board/TaskCard.tsx +24 -5
- package/src/client/board/TaskDetail.tsx +83 -33
- package/src/client/board/TaskForm.tsx +48 -3
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board.module.css +130 -136
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +50 -18
- package/src/client/locales.ts +82 -8
- package/src/client/native-panel.tsx +124 -0
- package/src/client/schedule-zone.ts +135 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +66 -4
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +10 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +38 -4
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host/agent-tools.ts +40 -8
- package/src/host-ledger.ts +223 -23
- package/src/host-routes.ts +37 -1
- package/src/host-runner.ts +164 -14
- package/src/host-service.ts +232 -39
- package/src/index.ts +23 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +27 -8
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -53
- package/src/host/run-guarded.ts +0 -76
package/src/host-runner.ts
CHANGED
|
@@ -60,6 +60,17 @@ function isDefinitionWithdrawn(error: unknown): boolean {
|
|
|
60
60
|
|
|
61
61
|
const SERVICE_UNAVAILABLE_ATTEMPTS = 5
|
|
62
62
|
const SERVICE_UNAVAILABLE_BACKOFF_MS = 2_000
|
|
63
|
+
/**
|
|
64
|
+
* Largest number of history pages one inspection walks back. A session whose
|
|
65
|
+
* window is wider than this cannot be resolved from its log, which the caller
|
|
66
|
+
* treats as an unreadable history rather than as pending work.
|
|
67
|
+
*/
|
|
68
|
+
const HISTORY_PAGE_LIMIT = 100
|
|
69
|
+
|
|
70
|
+
/** One-line text of a gateway or stream failure, for a decideable reason. */
|
|
71
|
+
function failureText(error: unknown): string {
|
|
72
|
+
return error instanceof Error ? error.message : String(error)
|
|
73
|
+
}
|
|
63
74
|
|
|
64
75
|
function delay(ms: number): Promise<void> {
|
|
65
76
|
return new Promise(resolve => { setTimeout(resolve, ms) })
|
|
@@ -87,7 +98,14 @@ export interface SessionCommandDispatcher {
|
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
export type ExecutionInspection =
|
|
90
|
-
|
|
101
|
+
/**
|
|
102
|
+
* No verdict yet. `unreadable` separates "the work may still be in progress"
|
|
103
|
+
* from "this session's history could not be read at all": a reader failure is
|
|
104
|
+
* not progress, and the caller decides how long to keep waiting before it
|
|
105
|
+
* reports the execution as undeterminable instead of holding a card open
|
|
106
|
+
* forever.
|
|
107
|
+
*/
|
|
108
|
+
| { outcome: 'pending'; unreadable?: true; reason?: string }
|
|
91
109
|
| { outcome: 'succeeded' }
|
|
92
110
|
| { outcome: 'failed'; error: string }
|
|
93
111
|
| { outcome: 'cancelled'; error: string }
|
|
@@ -136,6 +154,13 @@ export interface PromptContext {
|
|
|
136
154
|
peers?: readonly PromptPeer[]
|
|
137
155
|
/** True when those members run as teammates inside this session (Team Lead). */
|
|
138
156
|
team?: boolean
|
|
157
|
+
/**
|
|
158
|
+
* Scheduled-run provenance: the instant the cron rule actually fired and the
|
|
159
|
+
* IANA zone its wall clock was read in. Present only for a cron-triggered
|
|
160
|
+
* run, so the model can resolve an unqualified date in the run's own terms
|
|
161
|
+
* instead of guessing from the Host clock (issue #1722).
|
|
162
|
+
*/
|
|
163
|
+
schedule?: { triggeredAt: number; timeZone: string; cron: string }
|
|
139
164
|
}
|
|
140
165
|
|
|
141
166
|
/**
|
|
@@ -147,13 +172,27 @@ function peerPromptPreamble(context: PromptContext): string | undefined {
|
|
|
147
172
|
const peers = context.peers ?? []
|
|
148
173
|
if (peers.length === 0) return undefined
|
|
149
174
|
if (context.team === true) {
|
|
150
|
-
const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id)})`)
|
|
175
|
+
const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id, peer.id)})`)
|
|
151
176
|
return `本任务是 Agent Team 的 Lead:本次运行不额外开启独立会话,以下 ${peers.length} 个子任务成员已在本会话中作为 teammate 启动。用 list_agents / send_message / wait_agent 协调它们,用任务看板工具读写它们在看板上的卡片:\n${lines.join('\n')}`
|
|
152
177
|
}
|
|
153
178
|
const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(任务 ${peer.id})`)
|
|
154
179
|
return `本次运行同时并发开启 ${peers.length} 个独立 DSH 会话执行下列子任务成员(可用 task_board_* 工具查看它们的进度):\n${lines.join('\n')}`
|
|
155
180
|
}
|
|
156
181
|
|
|
182
|
+
/**
|
|
183
|
+
* The scheduled-run section: this execution was triggered by the card's cron
|
|
184
|
+
* rule rather than by a person. It states the firing instant, the rule's zone,
|
|
185
|
+
* and the rule text, so the run's own clock is unambiguous — the board's
|
|
186
|
+
* scheduler fires on a wall clock in a specific IANA zone, which is not
|
|
187
|
+
* necessarily the zone of whatever session the run lands in.
|
|
188
|
+
*/
|
|
189
|
+
function schedulePromptPreamble(context: PromptContext): string | undefined {
|
|
190
|
+
const schedule = context.schedule
|
|
191
|
+
if (schedule === undefined) return undefined
|
|
192
|
+
const stamp = new Date(schedule.triggeredAt).toISOString()
|
|
193
|
+
return `本次执行由任务看板的定时规则自动触发:触发时间 ${stamp}(UTC),规则时区 ${schedule.timeZone},cron 表达式 ${schedule.cron}。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`
|
|
194
|
+
}
|
|
195
|
+
|
|
157
196
|
/**
|
|
158
197
|
* Build the execution prompt for one task.
|
|
159
198
|
* @param task - the task being launched.
|
|
@@ -167,11 +206,14 @@ export function promptText(task: TaskRecord, context: PromptContext = {}): strin
|
|
|
167
206
|
? undefined
|
|
168
207
|
: `交接包引用(来自任务看板续接卡片,冻结于 ${new Date(handover.bundledAt).toISOString()}):\n${handover.references.map(reference => `- ${reference}`).join('\n')}`
|
|
169
208
|
// Tag prompts come first: they are the run's standing context (business line,
|
|
170
|
-
// output location), the handover preamble is a per-card note,
|
|
171
|
-
// body is the instruction
|
|
209
|
+
// output location), the handover preamble is a per-card note, the scheduled
|
|
210
|
+
// section states why this run exists, and the task body is the instruction.
|
|
172
211
|
const tagPreamble = tagPromptPreamble(task)
|
|
212
|
+
const handoverSection = handoverPreamble
|
|
213
|
+
const schedulePreamble = schedulePromptPreamble(context)
|
|
173
214
|
const runPreamble = peerPromptPreamble(context)
|
|
174
|
-
const preambles = [tagPreamble,
|
|
215
|
+
const preambles = [tagPreamble, handoverSection, schedulePreamble, runPreamble]
|
|
216
|
+
.filter((part): part is string => part !== undefined)
|
|
175
217
|
const preamble = preambles.length === 0 ? undefined : preambles.join('\n\n')
|
|
176
218
|
const freeze = task.freeze
|
|
177
219
|
if (freeze === undefined) {
|
|
@@ -182,6 +224,27 @@ export function promptText(task: TaskRecord, context: PromptContext = {}): strin
|
|
|
182
224
|
return preamble === undefined ? declaration : `${preamble}\n\n${declaration}`
|
|
183
225
|
}
|
|
184
226
|
|
|
227
|
+
/**
|
|
228
|
+
* The parts of an objective the built-in `/goal` command reads as an operation
|
|
229
|
+
* rather than as an objective: an exact `clear`/`pause`/`resume`/`edit`
|
|
230
|
+
* (case-insensitive) and a leading `edit <objective>`.
|
|
231
|
+
*/
|
|
232
|
+
const GOAL_COMMAND_KEYWORD = /^(?:clear|pause|resume|edit)$|^edit\s/i
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Build the objective the built-in `/goal` command receives. A prompt whose
|
|
236
|
+
* text would be read as a goal operation is prefixed with a neutral label: the
|
|
237
|
+
* instruction stays intact, and the command arms an objective instead of
|
|
238
|
+
* clearing, pausing, or editing an unrelated goal (or refusing for a missing
|
|
239
|
+
* one).
|
|
240
|
+
* @param prompt - the composed execution prompt.
|
|
241
|
+
* @returns the objective text for `/goal <objective>`.
|
|
242
|
+
*/
|
|
243
|
+
export function goalObjective(prompt: string): string {
|
|
244
|
+
const text = prompt.trim()
|
|
245
|
+
return GOAL_COMMAND_KEYWORD.test(text) ? `Goal: ${text}` : text
|
|
246
|
+
}
|
|
247
|
+
|
|
185
248
|
/**
|
|
186
249
|
* Build the tag section of the execution prompt (issue #1521). Only tags with
|
|
187
250
|
* a non-blank `promptPrefix` contribute; a task whose tags are all bare names
|
|
@@ -393,6 +456,82 @@ export class HostExecutionRunner {
|
|
|
393
456
|
mode: 'queue' as const,
|
|
394
457
|
content: [{ type: 'text' as const, text: promptText(task, context) }],
|
|
395
458
|
})
|
|
459
|
+
await this.armGoal(sessionId, task, context)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Arm dsh's built-in goal on the session. The task prompt is queued FIRST, so
|
|
464
|
+
* the session's first turn carries the instruction verbatim; `/goal
|
|
465
|
+
* <objective>` then turns the same text into a persistent objective, and
|
|
466
|
+
* dsh's goal-round driver keeps starting continuation rounds until the agent
|
|
467
|
+
* marks it complete (see {@link goalVerdict} for how the run settles).
|
|
468
|
+
*
|
|
469
|
+
* Opt-out: a task whose `goalRun` is an explicit false runs one plain turn.
|
|
470
|
+
* A refusal (no command dispatcher, no `/goal` command in this cohort, an
|
|
471
|
+
* objective the command rejects) is reported and the run continues as that
|
|
472
|
+
* plain turn: the task was asked to run, and a missing goal mode must not
|
|
473
|
+
* lose the work.
|
|
474
|
+
*/
|
|
475
|
+
private async armGoal(sessionId: ExecutionSessionId, task: TaskRecord, context: PromptContext): Promise<void> {
|
|
476
|
+
if (task.goalRun === false) return
|
|
477
|
+
if (this.commands === undefined) {
|
|
478
|
+
console.warn('[dsh-task-board] no command dispatcher is available; task ' + task.id + ' runs without /goal')
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
const command = await this.commands.execute(
|
|
483
|
+
sessionId,
|
|
484
|
+
'/goal ' + goalObjective(promptText(task, context)),
|
|
485
|
+
AbortSignal.timeout(30_000),
|
|
486
|
+
)
|
|
487
|
+
if (command === undefined) {
|
|
488
|
+
console.warn('[dsh-task-board] /goal was not acknowledged for session ' + sessionId + '; the run continues as a plain turn')
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
if (command.kind !== 'success') {
|
|
492
|
+
console.warn('[dsh-task-board] /goal was refused for session ' + sessionId + ': ' + (command.text ?? 'no reason reported') + '; the run continues as a plain turn')
|
|
493
|
+
}
|
|
494
|
+
} catch (error) {
|
|
495
|
+
console.warn('[dsh-task-board] could not arm /goal on session ' + sessionId + '; the run continues as a plain turn', error)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The verdict an armed dsh goal imposes on an otherwise completed turn.
|
|
501
|
+
*
|
|
502
|
+
* A goal run is one session working many automatic continuation rounds:
|
|
503
|
+
* dsh's goal-round driver queues the next round whenever the agent goes
|
|
504
|
+
* idle, so the first completed `turn/end` is not the execution's result.
|
|
505
|
+
* Settling there would leave the card in `done` while the session keeps
|
|
506
|
+
* working, and a scheduled task would fire a second, concurrent session.
|
|
507
|
+
* The session projection is the authority: an `active` goal keeps the
|
|
508
|
+
* execution pending, a `blocked` goal fails it with the recorded reason, and
|
|
509
|
+
* any other phase (complete, paused, or no goal at all) leaves the turn
|
|
510
|
+
* verdict in place.
|
|
511
|
+
*
|
|
512
|
+
* An unreadable projection falls back to that turn verdict: a cohort whose
|
|
513
|
+
* gateway withdrew `session/projections` must not hang every execution.
|
|
514
|
+
*/
|
|
515
|
+
private async goalVerdict(sessionId: string): Promise<ExecutionInspection | undefined> {
|
|
516
|
+
let projections: { values?: { goal?: unknown } } | null
|
|
517
|
+
try {
|
|
518
|
+
projections = await this.invoke('session', 'projections', { sessionId }) as { values?: { goal?: unknown } } | null
|
|
519
|
+
} catch (error) {
|
|
520
|
+
console.warn('[dsh-task-board] session/projections failed while reading the goal phase; settling from the turn result', error)
|
|
521
|
+
return undefined
|
|
522
|
+
}
|
|
523
|
+
const goal = projections?.values?.goal
|
|
524
|
+
if (goal === undefined || goal === null) return undefined
|
|
525
|
+
const view = (goal as { goal?: { phase?: unknown; blockedReason?: { message?: unknown } } }).goal
|
|
526
|
+
if (view?.phase === 'active') return { outcome: 'pending' }
|
|
527
|
+
if (view?.phase === 'blocked') {
|
|
528
|
+
const message = view.blockedReason?.message
|
|
529
|
+
return {
|
|
530
|
+
outcome: 'failed',
|
|
531
|
+
error: 'goal is blocked: ' + (typeof message === 'string' && message !== '' ? message : 'no reason recorded'),
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return undefined
|
|
396
535
|
}
|
|
397
536
|
|
|
398
537
|
async listRunning(): Promise<{ known: true; count: number; items: SessionSummary[] } | { known: false }> {
|
|
@@ -424,8 +563,17 @@ export class HostExecutionRunner {
|
|
|
424
563
|
}
|
|
425
564
|
}
|
|
426
565
|
|
|
427
|
-
/**
|
|
428
|
-
|
|
566
|
+
/**
|
|
567
|
+
* Resolve an execution outcome from the session list and bounded history pages.
|
|
568
|
+
* @param sessionId - the session the execution runs in.
|
|
569
|
+
* @param startedAt - instant the execution opened; earlier events are not its own.
|
|
570
|
+
* @param sessions - roster from the caller's own poll, when it already has one.
|
|
571
|
+
* @param options.whileRunning - read the first completed turn even while the
|
|
572
|
+
* roster reports the session as running. A durable Agent Teams teammate
|
|
573
|
+
* stays alive after its turn ends, so `running` never clears for it and the
|
|
574
|
+
* turn it completed is the only verdict it will ever expose.
|
|
575
|
+
*/
|
|
576
|
+
async inspect(sessionId: string, startedAt = 0, sessions?: readonly SessionSummary[], options: { whileRunning?: boolean } = {}): Promise<ExecutionInspection> {
|
|
429
577
|
let items: readonly SessionSummary[]
|
|
430
578
|
if (sessions !== undefined) {
|
|
431
579
|
items = sessions
|
|
@@ -455,7 +603,7 @@ export class HostExecutionRunner {
|
|
|
455
603
|
this.scanMemos.delete(sessionId)
|
|
456
604
|
return { outcome: 'cancelled', error: 'execution session no longer exists' }
|
|
457
605
|
}
|
|
458
|
-
if (summary.running) return { outcome: 'pending' }
|
|
606
|
+
if (summary.running && options.whileRunning !== true) return { outcome: 'pending' }
|
|
459
607
|
|
|
460
608
|
let opening: { cursor: number; records: readonly SessionHistoryRecord[]; hasMore: boolean }
|
|
461
609
|
try {
|
|
@@ -465,12 +613,12 @@ export class HostExecutionRunner {
|
|
|
465
613
|
if (typeof iterator.return === 'function') await iterator.return()
|
|
466
614
|
const follow = next.done === true ? undefined : next.value as { type?: string; cursor?: number; records?: readonly SessionHistoryRecord[]; hasMore?: boolean }
|
|
467
615
|
if (follow === undefined || follow.type !== 'snapshot' || typeof follow.cursor !== 'number' || follow.records === undefined || typeof follow.hasMore !== 'boolean') {
|
|
468
|
-
return { outcome: 'pending' }
|
|
616
|
+
return { outcome: 'pending', unreadable: true, reason: 'session/follow returned no opening frame' }
|
|
469
617
|
}
|
|
470
618
|
opening = { cursor: follow.cursor, records: follow.records, hasMore: follow.hasMore }
|
|
471
619
|
} catch (error) {
|
|
472
620
|
console.warn('[dsh-task-board] session/follow failed during execution inspection; keeping the outcome pending', error)
|
|
473
|
-
return { outcome: 'pending' }
|
|
621
|
+
return { outcome: 'pending', unreadable: true, reason: 'session/follow failed: ' + failureText(error) }
|
|
474
622
|
}
|
|
475
623
|
const openingEvents = opening.records.map(record => ({ event: recordEvent(record) }))
|
|
476
624
|
const newestSeq = openingEvents.reduce<number | undefined>((newest, entry) => newest === undefined ? entry.event.seq : Math.max(newest, entry.event.seq), undefined)
|
|
@@ -478,7 +626,7 @@ export class HostExecutionRunner {
|
|
|
478
626
|
const events: Array<{ event: { type: string; seq: number; time: number; data: unknown } }> = [...openingEvents]
|
|
479
627
|
let beforeSeq: number | undefined
|
|
480
628
|
let reachedExecutionBoundary = !opening.hasMore
|
|
481
|
-
for (let page = 0; page <
|
|
629
|
+
for (let page = 0; page < HISTORY_PAGE_LIMIT && !reachedExecutionBoundary; page += 1) {
|
|
482
630
|
let history: SessionPage
|
|
483
631
|
try {
|
|
484
632
|
history = await this.invoke('session', 'page', {
|
|
@@ -489,7 +637,7 @@ export class HostExecutionRunner {
|
|
|
489
637
|
}) as SessionPage
|
|
490
638
|
} catch (error) {
|
|
491
639
|
console.warn('[dsh-task-board] session/page failed during execution inspection; keeping the outcome pending', error)
|
|
492
|
-
return { outcome: 'pending' }
|
|
640
|
+
return { outcome: 'pending', unreadable: true, reason: 'session/page failed: ' + failureText(error) }
|
|
493
641
|
}
|
|
494
642
|
const pageEntries = pageEvents(history)
|
|
495
643
|
events.push(...pageEntries)
|
|
@@ -502,7 +650,9 @@ export class HostExecutionRunner {
|
|
|
502
650
|
if (oldestSeq === undefined || oldestSeq === beforeSeq) return { outcome: 'pending' }
|
|
503
651
|
beforeSeq = oldestSeq
|
|
504
652
|
}
|
|
505
|
-
if (!reachedExecutionBoundary)
|
|
653
|
+
if (!reachedExecutionBoundary) {
|
|
654
|
+
return { outcome: 'pending', unreadable: true, reason: 'history scan did not reach the execution start within ' + HISTORY_PAGE_LIMIT + ' pages' }
|
|
655
|
+
}
|
|
506
656
|
const turnEnd = events
|
|
507
657
|
.filter(entry => entry.event.type === 'turn/end' && (startedAt <= 0 || entry.event.time >= startedAt))
|
|
508
658
|
.sort((a, b) => a.event.seq - b.event.seq)[0]
|
|
@@ -512,7 +662,7 @@ export class HostExecutionRunner {
|
|
|
512
662
|
}
|
|
513
663
|
this.scanMemos.delete(sessionId)
|
|
514
664
|
const reason = nonCompletedTurnEnd(turnEnd.event.data)
|
|
515
|
-
if (reason === undefined) return { outcome: 'succeeded' }
|
|
665
|
+
if (reason === undefined) return await this.goalVerdict(sessionId) ?? { outcome: 'succeeded' }
|
|
516
666
|
// `error` keeps its historical wording; every other non-completed reason
|
|
517
667
|
// (aborted / blocked / max-tokens / interrupted) now reports instead of
|
|
518
668
|
// silently counting as success.
|