@linxin666/dsh-client-ui-task-board 0.4.5 → 0.4.6
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 +20 -12
- package/README.zh.md +20 -12
- package/lib/client.js +1327 -229
- package/lib/client.js.map +1 -1
- package/lib/index.js +1909 -348
- package/lib/types/client/TaskBoardSettingsCard.d.ts +12 -0
- package/lib/types/client/index.d.ts +4 -5
- package/lib/types/client/locales.d.ts +65 -0
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -0
- package/lib/types/core/controller.d.ts +10 -11
- package/lib/types/core/poll-cadence.d.ts +31 -0
- package/lib/types/core/tasks.d.ts +144 -14
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- package/lib/types/core/verification-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +376 -9
- package/lib/types/host/verification-gate.d.ts +24 -0
- package/lib/types/host/verification-runner.d.ts +38 -7
- package/lib/types/host-ledger.d.ts +82 -7
- package/lib/types/host-runner.d.ts +32 -9
- package/lib/types/host-service.d.ts +117 -9
- package/lib/types/index.d.ts +54 -0
- package/lib/types/protocol.d.ts +50 -12
- package/package.json +2 -6
- package/src/client/TaskBoardSettingsCard.tsx +74 -1
- package/src/client/board/NewTaskModal.tsx +151 -20
- package/src/client/board/TaskCard.tsx +3 -3
- package/src/client/board/TaskDetail.tsx +219 -18
- package/src/client/board/VerificationReport.tsx +132 -13
- package/src/client/board/status-key.ts +3 -0
- package/src/client/index.ts +43 -50
- package/src/client/locales.ts +137 -4
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- package/src/core/controller.ts +12 -8
- package/src/core/poll-cadence.ts +41 -0
- package/src/core/store.ts +64 -15
- package/src/core/tasks.ts +193 -39
- package/src/core/use-cases/task-create.ts +30 -9
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-update.ts +3 -3
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +785 -17
- package/src/host/agent-tools.ts +106 -21
- package/src/host/verification-gate.ts +172 -16
- package/src/host/verification-runner.ts +207 -40
- package/src/host-ledger.ts +328 -42
- package/src/host-runner.ts +52 -19
- package/src/host-service.ts +299 -36
- package/src/index.ts +101 -5
- package/src/protocol.ts +132 -28
- package/lib/invariant.js +0 -6
- package/lib/types/client/board-card-seat.d.ts +0 -34
- package/lib/types/invariant.d.ts +0 -3
- package/src/client/board-card-seat.ts +0 -102
- package/src/invariant.ts +0 -4
package/src/host/agent-tools.ts
CHANGED
|
@@ -23,11 +23,13 @@ import {
|
|
|
23
23
|
TASK_PERMISSIONS,
|
|
24
24
|
type ExecutionRecord,
|
|
25
25
|
type NewTaskInput,
|
|
26
|
+
type ScheduleMode,
|
|
26
27
|
type ScheduleRule,
|
|
27
28
|
type TaskPermission,
|
|
28
29
|
type TaskRecord,
|
|
29
30
|
type TaskTag,
|
|
30
31
|
} from '../core/tasks.ts'
|
|
32
|
+
import type { SetSchedulePatch } from '../core/use-cases/task-schedule.ts'
|
|
31
33
|
import type { TaskUpdatePatch } from '../core/use-cases/task-update.ts'
|
|
32
34
|
import type { TaskBoardAction, TaskBoardSnapshot } from '../protocol.ts'
|
|
33
35
|
|
|
@@ -93,11 +95,22 @@ export function callingSessionId(exec: { agent?: unknown }): string | undefined
|
|
|
93
95
|
function scheduleView(schedule: ScheduleRule): Record<string, unknown> {
|
|
94
96
|
return {
|
|
95
97
|
enabled: schedule.enabled,
|
|
96
|
-
|
|
98
|
+
mode: schedule.mode,
|
|
99
|
+
// A one-shot has no cron, and carries its planned instant instead.
|
|
100
|
+
...(schedule.cron === undefined ? {} : { cron: schedule.cron }),
|
|
101
|
+
...(schedule.at === undefined ? {} : { at: schedule.at }),
|
|
97
102
|
// Absent means the rule follows the Host zone, which the board view reports.
|
|
98
103
|
...(schedule.timeZone === undefined ? {} : { timeZone: schedule.timeZone }),
|
|
99
104
|
...(schedule.nextRunAt === undefined ? {} : { nextRunAt: schedule.nextRunAt }),
|
|
100
105
|
...(schedule.lastTriggeredAt === undefined ? {} : { lastTriggeredAt: schedule.lastTriggeredAt }),
|
|
106
|
+
// The run budget: what the scheduler has already opened, its cap (absent =
|
|
107
|
+
// unlimited), and how the rule stopped or what it last skipped.
|
|
108
|
+
runCount: schedule.runCount,
|
|
109
|
+
...(schedule.maxRuns === undefined ? {} : { maxRuns: schedule.maxRuns }),
|
|
110
|
+
...(schedule.endedAt === undefined ? {} : { endedAt: schedule.endedAt }),
|
|
111
|
+
...(schedule.endedReason === undefined ? {} : { endedReason: schedule.endedReason }),
|
|
112
|
+
...(schedule.skippedAt === undefined ? {} : { skippedAt: schedule.skippedAt }),
|
|
113
|
+
...(schedule.skippedReason === undefined ? {} : { skippedReason: schedule.skippedReason }),
|
|
101
114
|
}
|
|
102
115
|
}
|
|
103
116
|
|
|
@@ -114,8 +127,11 @@ function executionView(execution: ExecutionRecord): Record<string, unknown> {
|
|
|
114
127
|
...(execution.result === undefined ? {} : { result: execution.result }),
|
|
115
128
|
...(execution.error === undefined ? {} : { error: execution.error }),
|
|
116
129
|
// The session that asked for the run (audit only; client-asserted by the
|
|
117
|
-
// browser, the calling session here).
|
|
130
|
+
// browser, the calling session here). On an external record it names the
|
|
131
|
+
// outside agent instead, which is what distinguishes it in the history.
|
|
118
132
|
...(execution.initiatedBy === undefined ? {} : { initiatedBy: execution.initiatedBy }),
|
|
133
|
+
// Set only by record-external-outcome: the Host observed no session for it.
|
|
134
|
+
...(execution.external === true ? { external: true } : {}),
|
|
119
135
|
...(execution.runGroupId === undefined ? {} : { cascade: true }),
|
|
120
136
|
// A deferred cascade parent has settled its own turn but still waits for
|
|
121
137
|
// its subtasks; that is not a stuck card and the model must not retry it.
|
|
@@ -152,8 +168,8 @@ function taskSummary(
|
|
|
152
168
|
...(task.model === undefined ? {} : { model: task.model }),
|
|
153
169
|
...(task.reuseSession === true ? { reuseSession: true } : {}),
|
|
154
170
|
...(task.teamRun === true ? { teamRun: true } : {}),
|
|
155
|
-
// Default-
|
|
156
|
-
...(task.goalRun ===
|
|
171
|
+
// Default-OFF opt-in: only a card that asked for the goal run reports it.
|
|
172
|
+
...(task.goalRun === true ? { goalRun: true } : {}),
|
|
157
173
|
// Default-OFF opt-out: the deviation is what a reader must be told.
|
|
158
174
|
...(task.skipVerification === true ? { skipVerification: true } : {}),
|
|
159
175
|
...(task.schedule === undefined ? {} : { schedule: scheduleView(task.schedule) }),
|
|
@@ -341,7 +357,9 @@ function buildManageTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
341
357
|
'Move, archive, restore, delete, or settle one task board card.',
|
|
342
358
|
'move-backlog, move-todo, move-running, move-done and move-failed are the manual column moves and cover every column; a card the runner is executing (one with an open execution) cannot be moved.',
|
|
343
359
|
'A manual move writes the card column only and never fabricates an execution record, so the card reports a declaration rather than evidence of a run: done/failed declare work finished (or failed) outside a Host-run execution — human work, an external system, a decision made elsewhere — and running says the work is under way without a tracked session. Use task_board_run when the work should actually run in a session here.',
|
|
360
|
+
'record-external-outcome is the record-backed version of move-done and move-failed: use it when YOU, or another agent outside this Host, actually finished or failed the card with your own model, so the terminal column and the run history both derive from one execution record. It takes result (succeeded or failed) and initiatedBy (who completed it, for example your own agent name) plus an optional summary, and it is refused while the card still has an open execution: the Host stays the only authority over a run it is executing. It cannot record running or cancelled — those remain Host-only.',
|
|
344
361
|
'settle force-closes the open execution of a card the board can no longer observe (a stuck running card) and records it cancelled with the caller as the reason, so the card returns to the todo column and can be run again.',
|
|
362
|
+
'reset-verification clears the acceptance anomalies (timeouts, authentication failures, an unresolvable judge route, or an attempt the time budget ended) recorded on a card open execution, so the next update_goal(action: complete) runs a fresh acceptance. It is refused unless that execution really carries such anomalies, it never clears a quality verdict, and a spent anomaly budget otherwise HOLDS the card open instead of failing it: tell the user the acceptance environment is unusable and that this action is how they clear it once they have fixed it.',
|
|
345
363
|
'archive takes the whole subtask tree off the board and is refused while any member has an unsettled execution; restore brings the task, its ancestors and its subtree back; delete removes one card and is refused while it still has subtasks (detach or delete them first) or while it runs.',
|
|
346
364
|
'It cannot confirm a permission binding: the confirmation gate is a human act performed in the board UI.',
|
|
347
365
|
].join(' '),
|
|
@@ -350,13 +368,59 @@ function buildManageTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
350
368
|
action: {
|
|
351
369
|
type: 'string',
|
|
352
370
|
required: true,
|
|
353
|
-
enum: ['move-todo', 'move-backlog', 'move-running', 'move-done', 'move-failed', 'archive', 'restore', 'delete', 'settle'],
|
|
371
|
+
enum: ['move-todo', 'move-backlog', 'move-running', 'move-done', 'move-failed', 'archive', 'restore', 'delete', 'settle', 'reset-verification', 'record-external-outcome'],
|
|
354
372
|
description: 'The lifecycle operation to perform.',
|
|
355
373
|
},
|
|
374
|
+
// Only record-external-outcome reads these two; every other action
|
|
375
|
+
// ignores them, so the model can pass them without a second tool call.
|
|
376
|
+
result: {
|
|
377
|
+
type: 'string',
|
|
378
|
+
enum: ['succeeded', 'failed'],
|
|
379
|
+
description: 'record-external-outcome only: the verdict the outside agent actually reached.',
|
|
380
|
+
},
|
|
381
|
+
initiatedBy: {
|
|
382
|
+
type: 'string',
|
|
383
|
+
description: 'record-external-outcome only: who finished the work outside this Host (for example your own agent name).',
|
|
384
|
+
},
|
|
385
|
+
summary: {
|
|
386
|
+
type: 'string',
|
|
387
|
+
description: 'record-external-outcome only: optional free-text note stored with the recorded outcome.',
|
|
388
|
+
},
|
|
356
389
|
},
|
|
357
390
|
output: { schema: { type: 'json' }, render: renderJson },
|
|
358
391
|
async execute(args, exec) {
|
|
359
392
|
const initiator = callingSessionId(exec)
|
|
393
|
+
if (args.action === 'record-external-outcome') {
|
|
394
|
+
const result = args.result
|
|
395
|
+
const by = typeof args.initiatedBy === 'string' ? args.initiatedBy.trim() : ''
|
|
396
|
+
if (result !== 'succeeded' && result !== 'failed') {
|
|
397
|
+
return refused('missing-result', 'record-external-outcome needs result=succeeded or result=failed')
|
|
398
|
+
}
|
|
399
|
+
if (by === '') {
|
|
400
|
+
return refused('missing-initiator', 'record-external-outcome needs initiatedBy naming who completed the work outside this Host')
|
|
401
|
+
}
|
|
402
|
+
if (!host.snapshot().tasks.some(task => task.id === args.taskId)) {
|
|
403
|
+
return refused('task-not-found', 'no task with id ' + args.taskId)
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const snapshot = await host.apply(crypto.randomUUID(), {
|
|
407
|
+
kind: 'record-external-outcome',
|
|
408
|
+
taskId: args.taskId,
|
|
409
|
+
result,
|
|
410
|
+
initiatedBy: by,
|
|
411
|
+
...(typeof args.summary === 'string' && args.summary.trim() !== '' ? { summary: args.summary } : {}),
|
|
412
|
+
}, initiator)
|
|
413
|
+
const task = snapshot.tasks.find(item => item.id === args.taskId)
|
|
414
|
+
return json({
|
|
415
|
+
ok: true,
|
|
416
|
+
action: args.action,
|
|
417
|
+
...(task === undefined ? {} : { task: taskSummary(task, snapshot.tasks, snapshot.sessionDefaultPermission) }),
|
|
418
|
+
note: 'The outcome is recorded as its own execution, so the terminal column and the run history agree. Read it back with task_board_get.',
|
|
419
|
+
})
|
|
420
|
+
} catch (error) {
|
|
421
|
+
return refused('refused', messageOf(error))
|
|
422
|
+
}
|
|
423
|
+
}
|
|
360
424
|
const actions: Record<string, TaskBoardAction> = {
|
|
361
425
|
'move-todo': { kind: 'move', taskId: args.taskId, status: 'todo' },
|
|
362
426
|
'move-backlog': { kind: 'move', taskId: args.taskId, status: 'backlog' },
|
|
@@ -365,6 +429,7 @@ function buildManageTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
365
429
|
'move-failed': { kind: 'move', taskId: args.taskId, status: 'failed' },
|
|
366
430
|
archive: { kind: 'archive', taskId: args.taskId },
|
|
367
431
|
settle: { kind: 'settle', taskId: args.taskId },
|
|
432
|
+
'reset-verification': { kind: 'reset-verification', taskId: args.taskId },
|
|
368
433
|
restore: { kind: 'restore', taskId: args.taskId },
|
|
369
434
|
delete: { kind: 'delete', taskId: args.taskId },
|
|
370
435
|
}
|
|
@@ -390,37 +455,46 @@ function buildScheduleTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
390
455
|
return defineTool({
|
|
391
456
|
name: 'task_board_schedule',
|
|
392
457
|
description: [
|
|
393
|
-
'Arm, change, or disarm a task scheduled runs
|
|
394
|
-
'
|
|
458
|
+
'Arm, change, or disarm a task scheduled runs. Two kinds of plan exist: a recurring 5-field cron rule (day-of-month and day-of-week follow Vixie semantics: both restricted means OR, otherwise AND), and a one-shot that runs once at a future instant.',
|
|
459
|
+
'For a one-shot pass mode "once" with at (the planned instant, ms since epoch UTC). For a recurring rule pass mode "cron" (or omit mode) with a cron expression, optionally capped by maxRuns (a positive whole number of scheduled executions; omit for unlimited, pass 0 to clear a cap).',
|
|
460
|
+
'The wall clock is read in the rule stored IANA time zone; omit timeZone to use the Host zone reported by task_board_list/task_board_get. DST gaps are skipped and an ambiguous fall-back time fires once, at the earlier instant.',
|
|
395
461
|
'A due scheduled task runs the same cascade a manual run does, so a root task runs its whole subtask tree.',
|
|
396
|
-
'A schedule whose tree contains an unconfirmed above-default permission is refused and
|
|
397
|
-
'Missed occurrences during Host downtime are skipped, never queued; a task that is already running skips its occurrence.',
|
|
462
|
+
'The scheduler counts executions it actually starts; once a rule reaches its run budget it stops itself in the ledger, and a manual run never consumes the budget. A schedule whose tree contains an unconfirmed above-default permission is refused and does not consume the budget; the reason is reported in the list board summary as schedulerError.',
|
|
463
|
+
'Missed occurrences during Host downtime are skipped, never queued (a one-shot whose instant passed while the Host was down is stopped as missed); a task that is already running skips its occurrence without consuming the budget.',
|
|
398
464
|
].join(' '),
|
|
399
465
|
parameters: {
|
|
400
466
|
taskId: { type: 'string', required: true, description: 'The task whose schedule changes.' },
|
|
401
467
|
enabled: { type: 'boolean', description: 'Arm (true) or disarm (false) the schedule.' },
|
|
402
|
-
|
|
468
|
+
mode: { type: 'string', enum: ['cron', 'once'], description: 'The kind of plan: "cron" for a recurring rule, "once" for a single future instant. Omit to keep the stored kind.' },
|
|
469
|
+
cron: { type: 'string', description: '5-field cron expression of a recurring rule: minute hour day-of-month month day-of-week.' },
|
|
470
|
+
at: { type: 'integer', description: 'Planned instant of a one-shot, in milliseconds since the Unix epoch (UTC). Must be in the future.' },
|
|
403
471
|
timeZone: {
|
|
404
472
|
type: 'string',
|
|
405
|
-
description: 'IANA zone the
|
|
473
|
+
description: 'IANA zone the wall clock is read in (for example Asia/Shanghai). Omit to keep the stored zone, or pass an empty string to clear it back to the Host zone.',
|
|
406
474
|
},
|
|
475
|
+
maxRuns: { type: 'integer', description: 'Maximum scheduled executions of a recurring rule (positive whole number). Omit to keep the stored cap; pass 0 to clear it back to unlimited.' },
|
|
407
476
|
},
|
|
408
477
|
output: { schema: { type: 'json' }, render: renderJson },
|
|
409
478
|
async execute(args, exec) {
|
|
410
|
-
if (args.enabled === undefined && args.cron === undefined && args.
|
|
411
|
-
|
|
479
|
+
if (args.enabled === undefined && args.mode === undefined && args.cron === undefined && args.at === undefined
|
|
480
|
+
&& args.timeZone === undefined && args.maxRuns === undefined) {
|
|
481
|
+
return refused('nothing-to-change', 'pass enabled, mode, cron, at, timeZone and/or maxRuns')
|
|
412
482
|
}
|
|
413
483
|
// A missing card must read as a missing card: the ledger reports an
|
|
414
484
|
// unknown id through the same refusal as a malformed cron.
|
|
415
485
|
if (!host.snapshot().tasks.some(task => task.id === args.taskId)) {
|
|
416
486
|
return refused('task-not-found', 'no task with id ' + args.taskId)
|
|
417
487
|
}
|
|
418
|
-
const patch:
|
|
488
|
+
const patch: SetSchedulePatch = {
|
|
419
489
|
...(args.enabled === undefined ? {} : { enabled: args.enabled }),
|
|
490
|
+
...(args.mode === undefined ? {} : { mode: args.mode as ScheduleMode }),
|
|
420
491
|
...(args.cron === undefined ? {} : { cron: args.cron }),
|
|
492
|
+
...(args.at === undefined ? {} : { at: args.at }),
|
|
421
493
|
// An empty string is the documented way to clear the stored zone, so
|
|
422
494
|
// the model never has to emit an explicit null through JSON args.
|
|
423
495
|
...(args.timeZone === undefined ? {} : { timeZone: args.timeZone === '' ? null : args.timeZone }),
|
|
496
|
+
// 0 is the documented way to clear a run cap back to unlimited.
|
|
497
|
+
...(args.maxRuns === undefined ? {} : { maxRuns: args.maxRuns <= 0 ? null : args.maxRuns }),
|
|
424
498
|
}
|
|
425
499
|
try {
|
|
426
500
|
const snapshot = await host.apply(crypto.randomUUID(), { kind: 'set-schedule', taskId: args.taskId, patch }, callingSessionId(exec))
|
|
@@ -527,7 +601,7 @@ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
527
601
|
model: { type: 'string', description: 'Pinned model as provider/model (or a model id); omit for the host default or the parent value.' },
|
|
528
602
|
reuseSession: { type: 'boolean', description: 'Continue later runs in the previous execution session instead of a fresh conversation.' },
|
|
529
603
|
teamRun: { type: 'boolean', description: 'Run this task as an Agent Team: running it starts one Team Lead session and the Host spawns a teammate per subtask inside it. Omit or false for one independent session per member. Refused when the deployment serves no Agent Teams service.' },
|
|
530
|
-
goalRun: { type: 'boolean', description: 'Start each run with dsh built-in /goal so the session keeps working continuation rounds until the goal completes.
|
|
604
|
+
goalRun: { type: 'boolean', description: 'Start each run with dsh built-in /goal so the session keeps working continuation rounds until the goal completes. Off by default: omit (or pass false) for a single plain turn, and pass true to opt this card in. It only takes effect while the deployment global native /goal switch is on.' },
|
|
531
605
|
skipVerification: { type: 'boolean', description: 'Run this task without the board acceptance gate: update_goal may mark the goal complete without a passing acceptance, and the execution record is marked skipped. Default false (omit to inherit the board-wide acceptance switch). Only affects executions started afterwards.' },
|
|
532
606
|
tags: {
|
|
533
607
|
type: 'array',
|
|
@@ -544,10 +618,13 @@ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
544
618
|
schedule: {
|
|
545
619
|
type: 'object',
|
|
546
620
|
additionalProperties: false,
|
|
547
|
-
description: 'Arm a
|
|
621
|
+
description: 'Arm a schedule at creation time. Use mode "once" with at for a single future run, or mode "cron" (or omit mode) with a 5-field cron expression, optionally capped by maxRuns.',
|
|
548
622
|
properties: {
|
|
549
623
|
enabled: { type: 'boolean', required: true, description: 'Whether the schedule is armed.' },
|
|
550
|
-
|
|
624
|
+
mode: { type: 'string', enum: ['cron', 'once'], description: 'The kind of plan: "cron" for a recurring rule, "once" for a single future instant.' },
|
|
625
|
+
cron: { type: 'string', description: '5-field cron of a recurring rule: minute hour day-of-month month day-of-week.' },
|
|
626
|
+
at: { type: 'integer', description: 'Planned instant of a one-shot, in milliseconds since the Unix epoch (UTC). Must be in the future.' },
|
|
627
|
+
maxRuns: { type: 'integer', description: 'Maximum scheduled executions of a recurring rule (positive whole number); omit for unlimited.' },
|
|
551
628
|
},
|
|
552
629
|
},
|
|
553
630
|
},
|
|
@@ -570,10 +647,18 @@ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
570
647
|
...(args.model === undefined || args.model === '' ? {} : { model: args.model }),
|
|
571
648
|
...(args.reuseSession === true ? { reuseSession: true } : {}),
|
|
572
649
|
...(args.teamRun === true ? { teamRun: true } : {}),
|
|
573
|
-
...(args.goalRun ===
|
|
650
|
+
...(args.goalRun === true ? { goalRun: true } : {}),
|
|
574
651
|
...(args.skipVerification === true ? { skipVerification: true } : {}),
|
|
575
652
|
...(tags.length === 0 ? {} : { tags }),
|
|
576
|
-
...(args.schedule === undefined ? {} : {
|
|
653
|
+
...(args.schedule === undefined ? {} : {
|
|
654
|
+
schedule: {
|
|
655
|
+
enabled: args.schedule.enabled,
|
|
656
|
+
...(args.schedule.mode === undefined ? {} : { mode: args.schedule.mode as ScheduleMode }),
|
|
657
|
+
...(args.schedule.cron === undefined ? {} : { cron: args.schedule.cron }),
|
|
658
|
+
...(args.schedule.at === undefined ? {} : { at: args.schedule.at }),
|
|
659
|
+
...(args.schedule.maxRuns === undefined ? {} : { maxRuns: args.schedule.maxRuns }),
|
|
660
|
+
},
|
|
661
|
+
}),
|
|
577
662
|
}
|
|
578
663
|
try {
|
|
579
664
|
const snapshot = await host.apply(crypto.randomUUID(), { kind: 'create', id, input }, callingSessionId(exec))
|
|
@@ -621,7 +706,7 @@ function buildUpdateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
621
706
|
model: { type: 'string', description: 'New pinned model; an empty string clears it.' },
|
|
622
707
|
reuseSession: { type: 'boolean', description: 'Continue later runs in the previous execution session.' },
|
|
623
708
|
teamRun: { type: 'boolean', description: 'Switches this task between a plain cascade (one session per member) and an Agent Team run (Lead session plus a teammate per subtask).' },
|
|
624
|
-
goalRun: { type: 'boolean', description: 'Whether each run starts with dsh built-in /goal (default
|
|
709
|
+
goalRun: { type: 'boolean', description: 'Whether each run starts with dsh built-in /goal (default false, one plain turn). Pass true to opt this card in, or false to return it to the default.' },
|
|
625
710
|
skipVerification: { type: 'boolean', description: 'Whether this task runs without the board acceptance gate (default false, inherit the board-wide switch). Pass true to opt out; pass false to opt back in. Only affects executions started afterwards.' },
|
|
626
711
|
tags: {
|
|
627
712
|
type: 'array',
|
|
@@ -651,7 +736,7 @@ function buildUpdateTool(host: TaskBoardToolHost): ToolDefinition {
|
|
|
651
736
|
}
|
|
652
737
|
if (has(args, 'reuseSession')) patch.reuseSession = args.reuseSession === true
|
|
653
738
|
if (has(args, 'teamRun')) patch.teamRun = args.teamRun === true
|
|
654
|
-
if (has(args, 'goalRun')) patch.goalRun = args.goalRun ===
|
|
739
|
+
if (has(args, 'goalRun')) patch.goalRun = args.goalRun === true ? true : false
|
|
655
740
|
if (has(args, 'skipVerification')) patch.skipVerification = args.skipVerification === true ? true : false
|
|
656
741
|
if (has(args, 'tags')) {
|
|
657
742
|
const tags = args.tags ?? []
|
|
@@ -18,17 +18,36 @@
|
|
|
18
18
|
* - an anomaly (timeout, authentication, unparseable answer, an unusable judge
|
|
19
19
|
* route) is recorded separately from a quality verdict and is never treated
|
|
20
20
|
* as one;
|
|
21
|
+
* - exhausting the anomaly budget does NOT judge the card: an environment that
|
|
22
|
+
* cannot answer is not the work failing, so the cycle is held open with no
|
|
23
|
+
* judge call until a user clears the recorded anomalies (issue #1828);
|
|
24
|
+
* - an attempt the time budget ends before any verdict is recorded as its own
|
|
25
|
+
* stage and charged to no budget at all (issue #1828);
|
|
26
|
+
* - an INVALID acceptance (a veto whose required evidence is missing,
|
|
27
|
+
* unlocatable, or aimed at the empty-work baseline) is recorded in its own
|
|
28
|
+
* stage, spends its OWN bounded budget, and never consumes a quality attempt
|
|
29
|
+
* or fails the card; when that budget is spent the cycle is held for a human
|
|
30
|
+
* exactly like a spent anomaly budget;
|
|
21
31
|
* - concurrent completion calls for one execution share one acceptance.
|
|
32
|
+
|
|
33
|
+
* The validity check itself is program-level (`assessAcceptanceValidity` in
|
|
34
|
+
* `core/verification.ts`), applied after the judge answers and BEFORE any
|
|
35
|
+
* quality verdict is written: a stronger prompt alone could not enforce it.
|
|
22
36
|
*/
|
|
23
37
|
import type { PreToolDecision } from '@deepseek-ai/dsh-tools'
|
|
24
38
|
import type { LlmRuntime } from '@deepseek-ai/dsh-llm'
|
|
25
39
|
import {
|
|
26
40
|
MAX_EXCEPTION_ATTEMPTS,
|
|
41
|
+
MAX_INVALID_ATTEMPTS,
|
|
27
42
|
MAX_QUALITY_ATTEMPTS,
|
|
28
43
|
exceptionAttempts,
|
|
29
44
|
hasExceptionBudget,
|
|
30
45
|
finalFailureReason,
|
|
46
|
+
hasInvalidBudget,
|
|
31
47
|
hasQualityBudget,
|
|
48
|
+
invalidAttempts,
|
|
49
|
+
invalidAcceptanceFeedback,
|
|
50
|
+
invalidHoldReason,
|
|
32
51
|
passedAttempt,
|
|
33
52
|
qualityAttempts,
|
|
34
53
|
qualityFeedback,
|
|
@@ -37,6 +56,7 @@ import {
|
|
|
37
56
|
} from '../core/verification.ts'
|
|
38
57
|
import { collectEvidence, evidenceEvents, runAcceptance } from './verification-runner.ts'
|
|
39
58
|
import { renderWorkspaceEvidence, type WorkspaceChangeSource } from './workspace-evidence.ts'
|
|
59
|
+
import { DEFAULT_VERIFICATION_BUDGET_MS, verificationBudgetMs, verificationCallTimeoutMs } from '../core/verification-budget.ts'
|
|
40
60
|
import type { HostTaskLedger } from '../host-ledger.ts'
|
|
41
61
|
import type { ExecutionRecord, TaskRecord } from '../core/tasks.ts'
|
|
42
62
|
|
|
@@ -58,6 +78,16 @@ export interface GoalVerificationGateDeps {
|
|
|
58
78
|
* that serves none) simply means the judge sees the trajectory alone.
|
|
59
79
|
*/
|
|
60
80
|
workspaceChanges?: () => WorkspaceChangeSource | undefined
|
|
81
|
+
/**
|
|
82
|
+
* Ceiling of one judge request, read per acceptance (a row setting). Absent
|
|
83
|
+
* keeps the default, so a programmatic mount still judges.
|
|
84
|
+
*/
|
|
85
|
+
callTimeoutMs?: () => number
|
|
86
|
+
/**
|
|
87
|
+
* Total ceiling of one acceptance attempt, read per acceptance (a row
|
|
88
|
+
* setting). Absent keeps the default.
|
|
89
|
+
*/
|
|
90
|
+
budgetMs?: () => number
|
|
61
91
|
logger: { warn(message: string, ...rest: unknown[]): void }
|
|
62
92
|
now?: () => number
|
|
63
93
|
}
|
|
@@ -140,6 +170,80 @@ export function createGoalVerificationGate(deps: GoalVerificationGateDeps): (exe
|
|
|
140
170
|
)
|
|
141
171
|
}
|
|
142
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Hold the cycle open after its non-quality budget ran out.
|
|
175
|
+
*
|
|
176
|
+
* A judge route that times out, cannot authenticate or cannot be resolved is
|
|
177
|
+
* the ENVIRONMENT failing, not the work, so this records the attempt and
|
|
178
|
+
* refuses the completion claim without writing a `failedReason` and without
|
|
179
|
+
* blocking the goal (issue #1828). The execution therefore survives for a
|
|
180
|
+
* user who fixes the environment and clears the recorded anomalies, instead
|
|
181
|
+
* of carrying a verdict that only the clock produced.
|
|
182
|
+
*/
|
|
183
|
+
const hold = (
|
|
184
|
+
taskId: string,
|
|
185
|
+
executionId: string,
|
|
186
|
+
verification: ExecutionVerification,
|
|
187
|
+
message: string,
|
|
188
|
+
detail: string,
|
|
189
|
+
code: string,
|
|
190
|
+
): PreToolDecision => {
|
|
191
|
+
write(taskId, executionId, { ...verification, inFlight: undefined })
|
|
192
|
+
return deny(bounded(message), code, detail)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The refusal one recorded anomaly returns to the fixing agent. */
|
|
196
|
+
const anomalyRefusal = (spend: ExecutionVerification, error: string | undefined): PreToolDecision =>
|
|
197
|
+
deny(
|
|
198
|
+
bounded('[任务看板 · 验收异常] ' + (error ?? 'unknown') + '\n这是验收异常而非质量判定,本次未消耗质量验收额度(已用 ' + exceptionAttempts(spend).length + '/' + MAX_EXCEPTION_ATTEMPTS + ')。请修复环境后再次调用 update_goal(action: complete)。'),
|
|
199
|
+
'TASK_BOARD_VERIFICATION_ANOMALY',
|
|
200
|
+
error,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The refusal one recorded INVALID acceptance returns.
|
|
205
|
+
*
|
|
206
|
+
* The claim is still refused (no pass record exists), but the agent is told
|
|
207
|
+
* the veto itself was unusable and is explicitly NOT asked to repair anything
|
|
208
|
+
* on that basis. The reason and scores stay recorded for review; the quality
|
|
209
|
+
* budget is untouched.
|
|
210
|
+
*/
|
|
211
|
+
const invalidRefusal = (spend: ExecutionVerification, attempt: VerificationAttempt): PreToolDecision =>
|
|
212
|
+
deny(
|
|
213
|
+
bounded(invalidAcceptanceFeedback(attempt, MAX_INVALID_ATTEMPTS - invalidAttempts(spend).length)),
|
|
214
|
+
'TASK_BOARD_VERIFICATION_INVALID',
|
|
215
|
+
attempt.error,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Hold a cycle whose invalid-acceptance budget is spent.
|
|
220
|
+
*
|
|
221
|
+
* No usable veto was ever produced, so the board neither passes nor fails the
|
|
222
|
+
* card: it stops calling the judge and leaves the execution for a human. Only
|
|
223
|
+
* an explicit user action (or a rerun) resumes it, which is what bounds the
|
|
224
|
+
* retries.
|
|
225
|
+
*/
|
|
226
|
+
const invalidHeld = (taskId: string, executionId: string, spend: ExecutionVerification): PreToolDecision =>
|
|
227
|
+
hold(
|
|
228
|
+
taskId,
|
|
229
|
+
executionId,
|
|
230
|
+
spend,
|
|
231
|
+
'[任务看板 · 验收无效] ' + invalidHoldReason(invalidAttempts(spend).length),
|
|
232
|
+
'acceptance produced no usable veto; a user action must clear the recorded invalid acceptances',
|
|
233
|
+
'TASK_BOARD_VERIFICATION_INVALID',
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
/** The refusal a spent anomaly budget returns: no verdict, and no card failure. */
|
|
237
|
+
const anomalyHeld = (taskId: string, executionId: string, spend: ExecutionVerification): PreToolDecision =>
|
|
238
|
+
hold(
|
|
239
|
+
taskId,
|
|
240
|
+
executionId,
|
|
241
|
+
spend,
|
|
242
|
+
'[任务看板 · 验收异常] 验收异常额度已用尽(' + MAX_EXCEPTION_ATTEMPTS + '/' + MAX_EXCEPTION_ATTEMPTS + '):本执行周期不再调用裁判模型,也不把这次执行判为失败。\n这是环境异常而非质量判定:请如实告诉用户验收环境当前不可用,由用户修复环境后用 task_board_manage(action=reset-verification) 清除已记录的异常,再重新完成目标。',
|
|
243
|
+
'acceptance anomaly budget exhausted; a user action must clear the recorded anomalies',
|
|
244
|
+
'TASK_BOARD_VERIFICATION_ANOMALY',
|
|
245
|
+
)
|
|
246
|
+
|
|
143
247
|
/** Record one attempt and decide whether the completion claim may proceed. */
|
|
144
248
|
const settle = async (input: {
|
|
145
249
|
task: TaskRecord
|
|
@@ -171,14 +275,10 @@ export function createGoalVerificationGate(deps: GoalVerificationGateDeps): (exe
|
|
|
171
275
|
const attempts = [...verification.attempts, attempt]
|
|
172
276
|
const spend = { ...verification, attempts, inFlight: undefined }
|
|
173
277
|
if (exceptionAttempts(spend).length >= MAX_EXCEPTION_ATTEMPTS) {
|
|
174
|
-
return
|
|
278
|
+
return anomalyHeld(task.id, execution.id, spend)
|
|
175
279
|
}
|
|
176
280
|
write(task.id, execution.id, spend)
|
|
177
|
-
return
|
|
178
|
-
bounded('[任务看板 · 验收异常] ' + attempt.error + '\n这是验收异常而非质量判定,本次未消耗质量验收额度(已用 ' + exceptionAttempts(spend).length + '/' + MAX_EXCEPTION_ATTEMPTS + ')。请修复环境后再次调用 update_goal(action: complete)。'),
|
|
179
|
-
'TASK_BOARD_VERIFICATION_ANOMALY',
|
|
180
|
-
attempt.error,
|
|
181
|
-
)
|
|
281
|
+
return anomalyRefusal(spend, attempt.error)
|
|
182
282
|
}
|
|
183
283
|
write(task.id, execution.id, baselines)
|
|
184
284
|
const llm = deps.llm?.()
|
|
@@ -201,14 +301,19 @@ export function createGoalVerificationGate(deps: GoalVerificationGateDeps): (exe
|
|
|
201
301
|
}
|
|
202
302
|
const spend = { ...verification, attempts: [...verification.attempts, attempt], inFlight: undefined }
|
|
203
303
|
if (exceptionAttempts(spend).length >= MAX_EXCEPTION_ATTEMPTS) {
|
|
204
|
-
return
|
|
304
|
+
return anomalyHeld(task.id, execution.id, spend)
|
|
205
305
|
}
|
|
206
306
|
write(task.id, execution.id, spend)
|
|
207
307
|
return deny(bounded('[任务看板 · 验收异常] ' + attempt.error), 'TASK_BOARD_VERIFICATION_ANOMALY', attempt.error)
|
|
208
308
|
}
|
|
209
309
|
const session = (agent as { session?: unknown }).session
|
|
210
310
|
const evidence = collectEvidence(session, objective, execution.startedAt)
|
|
211
|
-
|
|
311
|
+
// Both ceilings are row settings read live: raising one unblocks a card
|
|
312
|
+
// whose acceptance is stuck on a slow route without a plugin reload.
|
|
313
|
+
const callTimeout = deps.callTimeoutMs?.() ?? verificationCallTimeoutMs(undefined)
|
|
314
|
+
const budget = deps.budgetMs?.() ?? DEFAULT_VERIFICATION_BUDGET_MS
|
|
315
|
+
const deadline = now() + budget
|
|
316
|
+
const acceptance = AbortSignal.timeout(budget)
|
|
212
317
|
// The host's own record of what changed on disk is offered next to the
|
|
213
318
|
// trajectory; an unavailable service or a failed read degrades to the
|
|
214
319
|
// trajectory alone instead of failing an acceptance already under way.
|
|
@@ -230,21 +335,42 @@ export function createGoalVerificationGate(deps: GoalVerificationGateDeps): (exe
|
|
|
230
335
|
context: workspace.text === '' ? undefined : workspace.text,
|
|
231
336
|
workspaceFiles: workspace.files,
|
|
232
337
|
signal: acceptance,
|
|
338
|
+
deadline,
|
|
339
|
+
callTimeoutMs: callTimeout,
|
|
233
340
|
now,
|
|
234
341
|
})
|
|
235
342
|
const attempt = result.attempt
|
|
236
343
|
const attempts = [...verification.attempts, attempt]
|
|
237
344
|
const spend: ExecutionVerification = { ...verification, attempts, inFlight: undefined }
|
|
345
|
+
// A budget stop is charged to no budget at all (issue #1828): the judge route
|
|
346
|
+
// may be healthy and the machine simply out of time, so it is neither an
|
|
347
|
+
// anomaly nor a verdict, and it must never close the card.
|
|
348
|
+
if (attempt.stage === 'budget') {
|
|
349
|
+
return hold(
|
|
350
|
+
task.id,
|
|
351
|
+
execution.id,
|
|
352
|
+
spend,
|
|
353
|
+
'[任务看板 · 验收未完成] ' + (attempt.error ?? '时间预算耗尽') + '\n本次没有产生任何质量判定,也不消耗质量或异常额度。请稍后重试;若裁判路由确实很慢,可调大验收的单次调用上限或总时间预算。',
|
|
354
|
+
attempt.error ?? 'the acceptance time budget ran out before a verdict',
|
|
355
|
+
'TASK_BOARD_VERIFICATION_BUDGET',
|
|
356
|
+
)
|
|
357
|
+
}
|
|
238
358
|
if (attempt.stage === 'exception') {
|
|
239
359
|
if (exceptionAttempts(spend).length >= MAX_EXCEPTION_ATTEMPTS) {
|
|
240
|
-
return
|
|
360
|
+
return anomalyHeld(task.id, execution.id, spend)
|
|
241
361
|
}
|
|
242
362
|
write(task.id, execution.id, spend)
|
|
243
|
-
return
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
363
|
+
return anomalyRefusal(spend, attempt.error)
|
|
364
|
+
}
|
|
365
|
+
// An invalid acceptance is recorded and returned to the agent, but it is
|
|
366
|
+
// NOT a quality verdict: it spends its own bounded budget, never the
|
|
367
|
+
// quality one, and it never fails the card.
|
|
368
|
+
if (attempt.stage === 'invalid') {
|
|
369
|
+
if (invalidAttempts(spend).length >= MAX_INVALID_ATTEMPTS) {
|
|
370
|
+
return invalidHeld(task.id, execution.id, spend)
|
|
371
|
+
}
|
|
372
|
+
write(task.id, execution.id, spend)
|
|
373
|
+
return invalidRefusal(spend, attempt)
|
|
248
374
|
}
|
|
249
375
|
if (attempt.passed) {
|
|
250
376
|
write(task.id, execution.id, spend)
|
|
@@ -255,7 +381,11 @@ export function createGoalVerificationGate(deps: GoalVerificationGateDeps): (exe
|
|
|
255
381
|
return finalize(task.id, execution.id, spend, finalFailureReason(attempt, used, verification.contract.threshold), agent)
|
|
256
382
|
}
|
|
257
383
|
write(task.id, execution.id, spend)
|
|
258
|
-
return deny(
|
|
384
|
+
return deny(
|
|
385
|
+
bounded(qualityFeedback(attempt, MAX_QUALITY_ATTEMPTS - used)),
|
|
386
|
+
'TASK_BOARD_VERIFICATION_FAILED',
|
|
387
|
+
locatedFindingDetail(attempt),
|
|
388
|
+
)
|
|
259
389
|
}
|
|
260
390
|
|
|
261
391
|
/** Evaluate one tool call; undefined means "not this gate's business". */
|
|
@@ -279,9 +409,21 @@ export function createGoalVerificationGate(deps: GoalVerificationGateDeps): (exe
|
|
|
279
409
|
verification.failedReason,
|
|
280
410
|
)
|
|
281
411
|
}
|
|
282
|
-
|
|
412
|
+
// A spent INVALID budget comes first: the judge produced no usable veto, so
|
|
413
|
+
// calling it again would only burn quota against a budget that determines
|
|
414
|
+
// nothing, and the card must stay open for a human rather than be failed.
|
|
415
|
+
if (!hasInvalidBudget(verification)) {
|
|
416
|
+
return invalidHeld(binding.task.id, binding.execution.id, verification)
|
|
417
|
+
}
|
|
418
|
+
if (!hasQualityBudget(verification)) {
|
|
283
419
|
return finalize(binding.task.id, binding.execution.id, verification, 'goal 验收额度已耗尽,本次 execution 判失败。', exec.agent)
|
|
284
420
|
}
|
|
421
|
+
// A spent anomaly budget is an environment that could not answer, not a
|
|
422
|
+
// judged work: the claim is refused and NO judge call is spent until a user
|
|
423
|
+
// clears the recorded anomalies (issue #1828).
|
|
424
|
+
if (!hasExceptionBudget(verification)) {
|
|
425
|
+
return anomalyHeld(binding.task.id, binding.execution.id, verification)
|
|
426
|
+
}
|
|
285
427
|
const key = binding.execution.id
|
|
286
428
|
const running = inFlight.get(key)
|
|
287
429
|
if (running !== undefined) return await running
|
|
@@ -325,6 +467,20 @@ export function createGoalVerificationGate(deps: GoalVerificationGateDeps): (exe
|
|
|
325
467
|
}
|
|
326
468
|
}
|
|
327
469
|
|
|
470
|
+
/**
|
|
471
|
+
* The structured detail of one quality refusal: the judge's own findings, in
|
|
472
|
+
* the order the acceptance recorded them, so the denial carries exactly the
|
|
473
|
+
* located evidence the report shows.
|
|
474
|
+
*/
|
|
475
|
+
function locatedFindingDetail(attempt: VerificationAttempt): string {
|
|
476
|
+
const lines: string[] = []
|
|
477
|
+
for (const finding of attempt.criterionFindings ?? []) {
|
|
478
|
+
lines.push('[' + finding.criterionId + ' ' + finding.location + '] "' + finding.quote + '"')
|
|
479
|
+
}
|
|
480
|
+
if (lines.length === 0) lines.push(...attempt.findings)
|
|
481
|
+
return lines.join('\n')
|
|
482
|
+
}
|
|
483
|
+
|
|
328
484
|
/** The bare execution prompt, used when the goal service carries no objective. */
|
|
329
485
|
function taskPrompt(task: TaskRecord): string {
|
|
330
486
|
return task.prompt.trim() !== '' ? task.prompt : task.title
|