@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-service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway'
|
|
2
|
-
import {
|
|
2
|
+
import { DEFAULT_SESSION_POLL_SECONDS, normalizeSessionPollSeconds, sessionPollMs } from './core/poll-cadence.ts'
|
|
3
3
|
import { reusableSessionId } from './core/session-reuse.ts'
|
|
4
4
|
import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts'
|
|
5
5
|
import { HostExecutionRunner, SessionLaunchError, promptText, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts'
|
|
@@ -9,8 +9,8 @@ import { PowerInhibitor } from './power-inhibitor.ts'
|
|
|
9
9
|
import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from './protocol.ts'
|
|
10
10
|
import { TaskBoardExtensionRegistry } from './host/extension-registry.ts'
|
|
11
11
|
import type { TaskBoardExtension } from './core/extension.ts'
|
|
12
|
-
import type { ExecutionOutcome, TaskStatus } from './core/tasks.ts'
|
|
13
|
-
import { passedAttempt, resolveContract, verificationRequired, type ExecutionVerification, type ModelCatalogView, type VerificationContract, type VerificationSettings } from './core/verification.ts'
|
|
12
|
+
import type { ExecutionOutcome, ScheduleMode, TaskRecord, TaskStatus } from './core/tasks.ts'
|
|
13
|
+
import { passedAttempt, resolveContract, verificationNeverInvoked, verificationOnlyInvalid, verificationRequired, withAcceptanceDetailCleared, type ExecutionVerification, type ModelCatalogView, type VerificationContract, type VerificationSettings } from './core/verification.ts'
|
|
14
14
|
import type { TaskPermission } from './core/handover.ts'
|
|
15
15
|
|
|
16
16
|
/** One teammate the Host asks the Agent Teams service to spawn for a team run. */
|
|
@@ -43,8 +43,8 @@ export interface TaskBoardTeamDispatcher {
|
|
|
43
43
|
spawn(input: TeamSpawnInput): Promise<TeamSpawnResult>
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
const
|
|
46
|
+
/** Ceiling for the roster-poll failure backoff: a broken session tree is retried at most once a minute. */
|
|
47
|
+
const POLL_FAILURE_MAX_BACKOFF_MS = 60_000
|
|
48
48
|
/**
|
|
49
49
|
* How late an armed schedule fire may be before it counts as a resume rather
|
|
50
50
|
* than a normal occurrence. The schedule timer is armed AT the next due
|
|
@@ -65,6 +65,31 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
|
65
65
|
*/
|
|
66
66
|
const UNREADABLE_SETTLE_POLLS = 24
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Why an enforced goal execution settled failed while its acceptance DID run:
|
|
70
|
+
* attempts were recorded and none of them passed. The judge answered about the
|
|
71
|
+
* work and the work did not pass.
|
|
72
|
+
*/
|
|
73
|
+
export const NO_MATCHING_PASS_VERIFICATION_REASON = 'goal 验收:本次执行没有匹配的验收通过记录(验收未运行、未通过或报告来自其他执行),按未验收判失败。'
|
|
74
|
+
/**
|
|
75
|
+
* Why an enforced goal execution settled failed with ZERO acceptance records:
|
|
76
|
+
* the gate was never opened, so the judge never ran and nothing was ever judged
|
|
77
|
+
* (issue #1837). The root cause is a session that declared completion in prose
|
|
78
|
+
* instead of calling `update_goal(action: complete)` — a tool-adherence problem,
|
|
79
|
+
* NOT a quality verdict on the delivery — so this reason says so and keeps the
|
|
80
|
+
* two investigation directions apart.
|
|
81
|
+
*/
|
|
82
|
+
export const NEVER_INVOKED_VERIFICATION_REASON = 'goal 验收未触发:本次执行没有任何验收记录,验收门从未打开——执行会话没有调用 update_goal(action: complete) 来完成目标(常见于只在回复里宣告完成)。这不是质量判负:交付从未被裁判评估。'
|
|
83
|
+
/**
|
|
84
|
+
* Why an enforced goal execution settled failed while its ONLY acceptance
|
|
85
|
+
* records are INVALID: the judge answered but could not locate its own
|
|
86
|
+
* rejection, so the board stopped calling it. This is NOT an evidence-backed
|
|
87
|
+
* quality veto — the requirement is explicit that such a run must not be
|
|
88
|
+
* described as one — but the execution still has no matching pass, so it ends
|
|
89
|
+
* for lack of one, and the reason names that precisely.
|
|
90
|
+
*/
|
|
91
|
+
export const INVALID_ONLY_VERIFICATION_REASON = 'goal 验收无效:本次执行只记录了无效验收(裁判未能给出与任务要求对应、且可定位到本次证据的否决依据),看板已停止调用裁判。这**不是**有证据的质量判负:验收本身不可用,本次运行因没有匹配的通过记录而结束。请检查验收环境与证据可见性,或用 task_board_manage(action=reset-verification) 清除记录的无效验收后重新完成目标。'
|
|
92
|
+
|
|
68
93
|
/**
|
|
69
94
|
* Provenance of one cron-triggered cascade: when the rule fired and the zone
|
|
70
95
|
* its wall clock was read in. Passed into every launched prompt of that run so
|
|
@@ -73,7 +98,10 @@ const UNREADABLE_SETTLE_POLLS = 24
|
|
|
73
98
|
export interface ScheduledRunContext {
|
|
74
99
|
triggeredAt: number
|
|
75
100
|
timeZone: string
|
|
76
|
-
|
|
101
|
+
/** Whether this fire came from a recurring rule or a single planned instant. */
|
|
102
|
+
mode: ScheduleMode
|
|
103
|
+
/** Cron expression of a recurring rule; absent on a one-shot. */
|
|
104
|
+
cron?: string
|
|
77
105
|
}
|
|
78
106
|
|
|
79
107
|
/**
|
|
@@ -113,6 +141,12 @@ export class TaskBoardHostService {
|
|
|
113
141
|
private readonly listeners = new Set<() => void>()
|
|
114
142
|
/** The one recurring timer: the session-roster poll. */
|
|
115
143
|
private pollTimer: (() => void) | undefined
|
|
144
|
+
/** Cadence of the roster poll, in seconds (a settings field; see the config schema). */
|
|
145
|
+
private sessionPollSeconds = DEFAULT_SESSION_POLL_SECONDS
|
|
146
|
+
/** Pending delay that follows a failed poll pass, if one is armed. */
|
|
147
|
+
private pollBackoffTimer: (() => void) | undefined
|
|
148
|
+
/** Consecutive failed poll passes; doubles the next retry delay. */
|
|
149
|
+
private consecutivePollFailures = 0
|
|
116
150
|
/** The armed schedule timer, if a trigger is pending. */
|
|
117
151
|
private scheduleTimer: (() => void) | undefined
|
|
118
152
|
/** The instant the armed schedule timer targets (ms epoch), for resume detection. */
|
|
@@ -120,13 +154,6 @@ export class TaskBoardHostService {
|
|
|
120
154
|
private disposed = false
|
|
121
155
|
private pollInFlight = false
|
|
122
156
|
private active = true
|
|
123
|
-
/**
|
|
124
|
-
* Ids the last roster poll saw as present and idle; undefined while the
|
|
125
|
-
* roster is unknown. Session reuse (issue #1419) requires this positive
|
|
126
|
-
* evidence, so a launch before the first successful poll mints a fresh
|
|
127
|
-
* conversation instead of prompting into a session it cannot see.
|
|
128
|
-
*/
|
|
129
|
-
private idleSessionIds: ReadonlySet<string> | undefined
|
|
130
157
|
/**
|
|
131
158
|
* Consecutive unreadable-history polls per open execution (see
|
|
132
159
|
* {@link noteUnreadableInspection}). Cleared as soon as an inspection
|
|
@@ -138,6 +165,8 @@ export class TaskBoardHostService {
|
|
|
138
165
|
private readonly verificationCatalog: () => Promise<ModelCatalogView | undefined>
|
|
139
166
|
private readonly team: TaskBoardTeamDispatcher | undefined
|
|
140
167
|
private readonly timers: HostTimerFace
|
|
168
|
+
/** Live reader of the global native-/goal switch; frozen per execution. */
|
|
169
|
+
private readonly goalRunEnabled: () => boolean
|
|
141
170
|
private lastPowerJson = ''
|
|
142
171
|
private readonly now: () => number
|
|
143
172
|
/** The deployment's workspaces, consulted to resolve a card that pins none. */
|
|
@@ -163,6 +192,14 @@ export class TaskBoardHostService {
|
|
|
163
192
|
maxSubtaskDepth?: number
|
|
164
193
|
team?: TaskBoardTeamDispatcher
|
|
165
194
|
timers?: HostTimerFace
|
|
195
|
+
/**
|
|
196
|
+
* Live GLOBAL native /goal switch (a volatile config read). Read ONCE per
|
|
197
|
+
* execution, at launch, and carried by value from then on: a settings edit
|
|
198
|
+
* made while a run is in flight must not arm, disarm or otherwise change the
|
|
199
|
+
* goal/acceptance contract of that run. Absent means OFF, which is the
|
|
200
|
+
* default for every deployment that never configured it.
|
|
201
|
+
*/
|
|
202
|
+
goalRunEnabled?: () => boolean
|
|
166
203
|
/**
|
|
167
204
|
* Live acceptance settings (volatile config reads). Absent keeps goal
|
|
168
205
|
* acceptance OFF for every execution this service opens, which is what a
|
|
@@ -182,6 +219,7 @@ export class TaskBoardHostService {
|
|
|
182
219
|
this.timers = options.timers ?? PROCESS_TIMERS
|
|
183
220
|
this.power = options.power ?? new PowerInhibitor()
|
|
184
221
|
this.now = options.now ?? Date.now
|
|
222
|
+
this.goalRunEnabled = options.goalRunEnabled ?? (() => false)
|
|
185
223
|
this.verificationSettings = options.verificationSettings ?? (() => ({ enabled: false, model: '', reasoningEffort: '' }))
|
|
186
224
|
this.verificationCatalog = options.verificationCatalog ?? (async () => undefined)
|
|
187
225
|
this.extensions = new TaskBoardExtensionRegistry({
|
|
@@ -210,8 +248,11 @@ export class TaskBoardHostService {
|
|
|
210
248
|
start(): void {
|
|
211
249
|
if (this.disposed || this.pollTimer !== undefined) return
|
|
212
250
|
this.syncPowerReasons()
|
|
213
|
-
this.pollTimer = this.timers.interval(() => { this.schedulePoll() },
|
|
251
|
+
this.pollTimer = this.timers.interval(() => { this.schedulePoll() }, sessionPollMs(this.sessionPollSeconds))
|
|
214
252
|
this.schedulePoll()
|
|
253
|
+
// Boot is also the cleanup catch-up point: an execution that passed before
|
|
254
|
+
// the Host stopped may still hold its detail (a crash, a failed write).
|
|
255
|
+
this.cleanupPendingAcceptanceDetail()
|
|
215
256
|
// Boot is a recovery point: an occurrence armed while the Host was down is
|
|
216
257
|
// not replayed, and each schedule rolls to its next future target. A
|
|
217
258
|
// schedule the Board should have served while running is then armed
|
|
@@ -219,10 +260,30 @@ export class TaskBoardHostService {
|
|
|
219
260
|
this.recoverSchedule()
|
|
220
261
|
}
|
|
221
262
|
|
|
222
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Apply the live configuration. The roster cadence is re-read on every
|
|
265
|
+
* commit, so a settings edit takes effect without a restart; the poll is
|
|
266
|
+
* scheduled at the new cadence immediately rather than waiting out the old
|
|
267
|
+
* interval.
|
|
268
|
+
* @param active - the board's master switch.
|
|
269
|
+
* @param preventIdleSleep - whether the board holds an idle-sleep assertion.
|
|
270
|
+
* @param sessionPollSeconds - roster-poll cadence in seconds (clamped).
|
|
271
|
+
*/
|
|
272
|
+
setConfiguration(active: boolean, preventIdleSleep: boolean, sessionPollSeconds?: number): void {
|
|
223
273
|
const resumed = !this.active && active
|
|
224
274
|
this.active = active
|
|
225
275
|
this.preventIdleSleep = preventIdleSleep
|
|
276
|
+
const cadence = normalizeSessionPollSeconds(sessionPollSeconds)
|
|
277
|
+
const retimed = this.pollTimer !== undefined && cadence !== this.sessionPollSeconds
|
|
278
|
+
this.sessionPollSeconds = cadence
|
|
279
|
+
if (retimed) {
|
|
280
|
+
// The recurring timer was armed at the old cadence: replace it and take
|
|
281
|
+
// the next pass now, so the new interval starts from this commit.
|
|
282
|
+
this.pollTimer?.()
|
|
283
|
+
this.pollTimer = this.timers.interval(() => { this.schedulePoll() }, sessionPollMs(this.sessionPollSeconds))
|
|
284
|
+
this.clearPollBackoff()
|
|
285
|
+
this.schedulePoll()
|
|
286
|
+
}
|
|
226
287
|
if (resumed) {
|
|
227
288
|
const current = this.power.snapshot()
|
|
228
289
|
this.power.updateReasons({
|
|
@@ -258,6 +319,10 @@ export class TaskBoardHostService {
|
|
|
258
319
|
sessionDefaultPermission: this.ledger.sessionDefaultPermission,
|
|
259
320
|
maxSubtaskDepth: this.ledger.maxSubtaskDepth,
|
|
260
321
|
teamRunAvailable: this.team !== undefined,
|
|
322
|
+
// The GLOBAL native-/goal switch is published live for the UI, but the
|
|
323
|
+
// service freezes its own read at launch: this mirror is for the FORM
|
|
324
|
+
// (enable/disable + explanation), never for a run already in flight.
|
|
325
|
+
goalRunEnabled: this.goalRunEnabled(),
|
|
261
326
|
...(Object.keys(published).length === 0 ? {} : { extensions: published }),
|
|
262
327
|
}
|
|
263
328
|
}
|
|
@@ -411,6 +476,7 @@ export class TaskBoardHostService {
|
|
|
411
476
|
this.disposed = true
|
|
412
477
|
this.extensions.dispose()
|
|
413
478
|
this.clearScheduleTimer()
|
|
479
|
+
this.clearPollBackoff()
|
|
414
480
|
this.pollTimer?.()
|
|
415
481
|
this.pollTimer = undefined
|
|
416
482
|
this.power.dispose()
|
|
@@ -419,12 +485,14 @@ export class TaskBoardHostService {
|
|
|
419
485
|
}
|
|
420
486
|
|
|
421
487
|
private async launch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): Promise<void> {
|
|
488
|
+
/** The session this launch attached to the execution, once it exists. */
|
|
489
|
+
let attached: string | undefined
|
|
422
490
|
try {
|
|
423
491
|
// A team run always mints a fresh Lead session: teammates are immutable
|
|
424
492
|
// children of that session, so reusing an older one would collide on
|
|
425
493
|
// their names and orphan the previous team.
|
|
426
494
|
const team = opened.task.teamRun === true
|
|
427
|
-
const reuseSessionId = team ? undefined :
|
|
495
|
+
const reuseSessionId = team ? undefined : await this.reuseSessionFor(opened.task)
|
|
428
496
|
// Both modes tell the launched agent what else this run opens; only a team
|
|
429
497
|
// run names teammates, because only then does this session own them.
|
|
430
498
|
const peers = others.length === 0 ? undefined : others.map(other => ({
|
|
@@ -445,6 +513,13 @@ export class TaskBoardHostService {
|
|
|
445
513
|
// same contract from an OFF switch: the board-wide switch decides the
|
|
446
514
|
// default, the card decides this execution, and both are read here.
|
|
447
515
|
const settings = this.verificationSettings()
|
|
516
|
+
// The GLOBAL native-/goal switch is read ONCE here and carried by value
|
|
517
|
+
// for the whole execution: a settings edit after this point can only
|
|
518
|
+
// affect executions that start later.
|
|
519
|
+
const globalGoalEnabled = this.goalRunEnabled() === true
|
|
520
|
+
// The card must ASK for the goal run: the option is off by default, so an
|
|
521
|
+
// absent value is a plain turn exactly like an explicit false.
|
|
522
|
+
const goalEnabled = globalGoalEnabled && opened.task.goalRun === true
|
|
448
523
|
// The card's opt-out is a DISTINCT reason, not the board switch: the
|
|
449
524
|
// report must be able to say which of the two turned the gate off.
|
|
450
525
|
const skippedBy: ExecutionVerification['applicability'] = opened.task.skipVerification === true ? 'skipped' : 'disabled'
|
|
@@ -452,16 +527,28 @@ export class TaskBoardHostService {
|
|
|
452
527
|
{ ...settings, enabled: settings.enabled && opened.task.skipVerification !== true },
|
|
453
528
|
await this.verificationCatalog(),
|
|
454
529
|
)
|
|
530
|
+
// An execution that will not become a goal run can never fire the
|
|
531
|
+
// completion gate, so it is marked with the reason that actually applied:
|
|
532
|
+
// the global switch being off is its OWN reason, distinct from this run
|
|
533
|
+
// simply not becoming a goal run (the card never opted in, or a refused or
|
|
534
|
+
// unavailable /goal command), and it must never read as an accepted run.
|
|
535
|
+
// The reason names which switch withheld the goal: the GLOBAL master
|
|
536
|
+
// switch (this board never arms /goal) is a different fact from the card's
|
|
537
|
+
// own goal option staying off — which is the default, so it is what an
|
|
538
|
+
// untouched card gets.
|
|
539
|
+
const notArmed: ExecutionVerification['applicability'] = globalGoalEnabled ? 'goal-unavailable' : 'goal-disabled'
|
|
455
540
|
const initial: ExecutionVerification = {
|
|
456
541
|
contract,
|
|
457
542
|
attempts: [],
|
|
458
|
-
applicability: team ? 'team-member' : !contract.enabled ? skippedBy :
|
|
543
|
+
applicability: team ? 'team-member' : !contract.enabled ? skippedBy : notArmed,
|
|
459
544
|
}
|
|
460
545
|
this.ledger.setVerification(opened.task.id, opened.execution.id, initial)
|
|
461
|
-
let attached: string | undefined
|
|
462
546
|
const sessionId = await this.runner.launch(opened.task, {
|
|
463
547
|
...(reuseSessionId === undefined ? {} : { reuseSessionId }),
|
|
464
548
|
...(promptContext === undefined ? {} : { promptContext }),
|
|
549
|
+
// The frozen global switch travels with the launch, so the runner's
|
|
550
|
+
// arming decision is exactly the one made above.
|
|
551
|
+
goalEnabled,
|
|
465
552
|
// Bind the session to the execution before the prompt is queued, so the
|
|
466
553
|
// completion gate can never observe the agent without its binding.
|
|
467
554
|
onSession: (id) => {
|
|
@@ -471,14 +558,22 @@ export class TaskBoardHostService {
|
|
|
471
558
|
onGoalArmed: (armed) => {
|
|
472
559
|
this.setApplicability(opened.task.id, opened.execution.id, team
|
|
473
560
|
? 'team-member'
|
|
474
|
-
: !contract.enabled ? skippedBy : armed ? 'enforced' :
|
|
561
|
+
: !contract.enabled ? skippedBy : armed ? 'enforced' : notArmed)
|
|
475
562
|
},
|
|
476
563
|
})
|
|
477
564
|
if (attached === undefined) this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
|
|
478
565
|
if (team) for (const teammate of others) this.scheduleTeammate(teammate, sessionId)
|
|
479
566
|
} catch (error) {
|
|
480
|
-
|
|
481
|
-
|
|
567
|
+
const launchedSession = error instanceof SessionLaunchError ? error.sessionId : undefined
|
|
568
|
+
if (launchedSession !== undefined) {
|
|
569
|
+
this.ledger.attachSession(opened.task.id, opened.execution.id, launchedSession)
|
|
570
|
+
}
|
|
571
|
+
// A scheduled occurrence that failed before any session existed created
|
|
572
|
+
// no real execution: refund the run it consumed and record why, so a
|
|
573
|
+
// failed launch can never silently spend the user's run budget. A manual
|
|
574
|
+
// run, and a launch that DID reach a session, are never refunded.
|
|
575
|
+
if (schedule !== undefined && attached === undefined && launchedSession === undefined) {
|
|
576
|
+
this.ledger.refundScheduledOccurrence(opened.task.id, this.now())
|
|
482
577
|
}
|
|
483
578
|
this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
|
|
484
579
|
}
|
|
@@ -520,21 +615,42 @@ export class TaskBoardHostService {
|
|
|
520
615
|
}
|
|
521
616
|
}
|
|
522
617
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
618
|
+
/**
|
|
619
|
+
* One roster pass, and whether it could read the session tree.
|
|
620
|
+
*
|
|
621
|
+
* The board reads the DSH session roster only while it has something the
|
|
622
|
+
* roster can decide: an execution to inspect, or a card parked in the running
|
|
623
|
+
* column whose verdict may arrive from a settle this process never saw. That
|
|
624
|
+
* gate is what removes the cost the reporter measured — `session/list`
|
|
625
|
+
* rebuilds every persisted session row (string conversion, object allocation,
|
|
626
|
+
* a stat per record), and an idle board with no tasks was paying it every few
|
|
627
|
+
* seconds forever.
|
|
628
|
+
* @returns whether the roster was readable (an idle pass counts as readable).
|
|
629
|
+
*/
|
|
630
|
+
private async pollSessions(): Promise<boolean> {
|
|
631
|
+
if (this.disposed) return true
|
|
632
|
+
const runtime = this.ledger.runtimeView()
|
|
633
|
+
if (!this.active) {
|
|
634
|
+
// A disabled board still settles the runs it opened before it was
|
|
635
|
+
// switched off, and re-reads the roster while a card sits in the running
|
|
636
|
+
// column. With neither, the poll is a no-op.
|
|
637
|
+
if (runtime.openExecutions.length === 0 && runtime.armedSchedules === 0) return true
|
|
638
|
+
} else if (!runtime.needsSessionState) {
|
|
639
|
+
// Idle board: nothing to reconcile and nothing to count. Keep the last
|
|
640
|
+
// power reading (a stale count only delays the release of the idle-sleep
|
|
641
|
+
// assertion, never starts one) and stand down.
|
|
642
|
+
return true
|
|
643
|
+
}
|
|
526
644
|
const running = await this.runner.listRunning()
|
|
527
645
|
const previous = this.power.snapshot()
|
|
528
646
|
if (!running.known) {
|
|
529
|
-
this.idleSessionIds = undefined
|
|
530
647
|
this.power.updateReasons({
|
|
531
648
|
runningSessions: previous.runningSessions,
|
|
532
|
-
armedSchedules:
|
|
649
|
+
armedSchedules: runtime.armedSchedules,
|
|
533
650
|
sessionStateKnown: false,
|
|
534
651
|
})
|
|
535
|
-
return
|
|
652
|
+
return false
|
|
536
653
|
}
|
|
537
|
-
this.idleSessionIds = new Set(running.items.filter(item => !item.running).map(item => item.sessionId))
|
|
538
654
|
// Fold whatever the board can already decide before spending inspection
|
|
539
655
|
// RPCs: a team run whose Lead recorded its verdict, and any lineage whose
|
|
540
656
|
// members are all settled. Idempotent, so an already folded board is free.
|
|
@@ -543,15 +659,44 @@ export class TaskBoardHostService {
|
|
|
543
659
|
this.emitStatusChanges(foldedBefore)
|
|
544
660
|
// Read after the RPC so executions attached while it was in flight are
|
|
545
661
|
// included in this pass, matching the former full-state snapshot timing.
|
|
546
|
-
const
|
|
662
|
+
const current = this.ledger.runtimeView()
|
|
547
663
|
this.power.updateReasons({
|
|
548
664
|
runningSessions: running.count,
|
|
549
|
-
armedSchedules:
|
|
665
|
+
armedSchedules: current.armedSchedules,
|
|
550
666
|
sessionStateKnown: true,
|
|
551
667
|
})
|
|
552
668
|
// No unconditional emit here: real changes already emit through the
|
|
553
669
|
// ledger subscription (settles) and the gated power listener above.
|
|
554
|
-
await this.reconcileExecutions(running.items,
|
|
670
|
+
await this.reconcileExecutions(running.items, current.openExecutions)
|
|
671
|
+
return true
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The session a run may continue in, for a card that opted into reuse
|
|
676
|
+
* (issue #1419). Reuse requires positive evidence that the previous session
|
|
677
|
+
* is present and idle, and that evidence must be read for THIS launch: an
|
|
678
|
+
* idle board no longer polls, so a roster cached from an earlier pass could
|
|
679
|
+
* be hours old — either refusing a session that has been idle all along, or
|
|
680
|
+
* prompting into one that started running since. A card that did not opt in
|
|
681
|
+
* reads no roster at all, and a read that fails mints a fresh conversation
|
|
682
|
+
* exactly as an unknown roster always did.
|
|
683
|
+
* @param task - the task about to run.
|
|
684
|
+
* @returns the session id to continue in, or undefined for a fresh session.
|
|
685
|
+
*/
|
|
686
|
+
private async reuseSessionFor(task: TaskRecord): Promise<string | undefined> {
|
|
687
|
+
if (task.reuseSession !== true) return undefined
|
|
688
|
+
let running: Awaited<ReturnType<HostExecutionRunner['listRunning']>>
|
|
689
|
+
try {
|
|
690
|
+
running = await this.runner.listRunning()
|
|
691
|
+
} catch (error) {
|
|
692
|
+
// A read that failed is an unknown roster, and an unknown roster never
|
|
693
|
+
// reuses; the launch itself must not fail over the reuse probe.
|
|
694
|
+
this.notePollFailure()
|
|
695
|
+
safeConsoleError('[dsh-task-board] session roster read for session reuse failed; starting a fresh session', error)
|
|
696
|
+
return undefined
|
|
697
|
+
}
|
|
698
|
+
if (!running.known) return undefined
|
|
699
|
+
return reusableSessionId(task, new Set(running.items.filter(item => !item.running).map(item => item.sessionId)))
|
|
555
700
|
}
|
|
556
701
|
|
|
557
702
|
/** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
|
|
@@ -588,11 +733,20 @@ export class TaskBoardHostService {
|
|
|
588
733
|
// be mistaken for a verified success. Only a matching pass record
|
|
589
734
|
// settles this execution as succeeded.
|
|
590
735
|
if (result.outcome === 'succeeded' && verificationRequired(verification) && passedAttempt(verification) === undefined) {
|
|
736
|
+
// Two different failures share this branch, and they send the reader
|
|
737
|
+
// in opposite directions: attempts on record mean the judge ran and
|
|
738
|
+
// the work did not pass, while zero attempts mean the gate never
|
|
739
|
+
// opened at all (issue #1837) — the session narrated completion
|
|
740
|
+
// instead of calling `update_goal(action: complete)`.
|
|
591
741
|
this.settleAndNotify(
|
|
592
742
|
execution.taskId,
|
|
593
743
|
execution.executionId,
|
|
594
744
|
'failed',
|
|
595
|
-
|
|
745
|
+
verificationNeverInvoked(verification)
|
|
746
|
+
? NEVER_INVOKED_VERIFICATION_REASON
|
|
747
|
+
: verificationOnlyInvalid(verification)
|
|
748
|
+
? INVALID_ONLY_VERIFICATION_REASON
|
|
749
|
+
: NO_MATCHING_PASS_VERIFICATION_REASON,
|
|
596
750
|
)
|
|
597
751
|
continue
|
|
598
752
|
}
|
|
@@ -636,6 +790,73 @@ export class TaskBoardHostService {
|
|
|
636
790
|
outcome,
|
|
637
791
|
...(error === undefined ? {} : { error }),
|
|
638
792
|
})
|
|
793
|
+
// The pass record and the settlement are DURABLE by this point (both are in
|
|
794
|
+
// the ledger above), so the acceptance-only detail may now be cleaned. A
|
|
795
|
+
// cleanup failure is recorded against the execution and retried on the next
|
|
796
|
+
// Host start; it never revokes the pass this settlement just wrote.
|
|
797
|
+
if (outcome === 'succeeded') this.cleanupAcceptanceDetail(taskId, executionId)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Remove one execution's acceptance-only detail after it PASSED and settled.
|
|
802
|
+
*
|
|
803
|
+
* Deliberately narrow: only the acceptance mechanism's own per-attempt
|
|
804
|
+
* material (findings, invalid diagnostics, the bulky judge side) is removed,
|
|
805
|
+
* and only for THIS execution, keyed by its own id in the ledger it belongs
|
|
806
|
+
* to. No user file, task artifact, raw session history or other execution's
|
|
807
|
+
* record is read or written. Idempotent by the record's own state, and it
|
|
808
|
+
* clearly never touches a run that did not pass (the caller's guard) — a
|
|
809
|
+
* failed or invalid execution keeps everything a repair or a review needs.
|
|
810
|
+
* @param taskId - the task whose execution settled.
|
|
811
|
+
* @param executionId - the settled execution.
|
|
812
|
+
*/
|
|
813
|
+
private cleanupAcceptanceDetail(taskId: string, executionId: string): void {
|
|
814
|
+
const execution = this.ledger.getTask(taskId)?.executions.find(entry => entry.id === executionId)
|
|
815
|
+
const verification = execution?.verification
|
|
816
|
+
// Only an execution with a REAL pass record is cleaned. An execution whose
|
|
817
|
+
// acceptance never ran (no block) or was disabled/skipped has nothing this
|
|
818
|
+
// mechanism created, and a failed one must keep its evidence.
|
|
819
|
+
if (verification === undefined || passedAttempt(verification) === undefined) return
|
|
820
|
+
if (verification.cleanup?.state === 'cleaned') return
|
|
821
|
+
try {
|
|
822
|
+
const cleaned = withAcceptanceDetailCleared(verification, this.now())
|
|
823
|
+
if (cleaned === undefined) return
|
|
824
|
+
this.ledger.setVerification(taskId, executionId, cleaned)
|
|
825
|
+
} catch (error) {
|
|
826
|
+
// Record the failure so the UI can say the cleanup is pending and the
|
|
827
|
+
// Host retries it at start; the pass itself is untouched.
|
|
828
|
+
const attempts = (verification.cleanup?.attempts ?? 0) + 1
|
|
829
|
+
this.ledger.setVerification(taskId, executionId, {
|
|
830
|
+
...verification,
|
|
831
|
+
cleanup: {
|
|
832
|
+
state: 'failed',
|
|
833
|
+
attempts,
|
|
834
|
+
startedAt: verification.cleanup?.startedAt ?? this.now(),
|
|
835
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
836
|
+
audit: verification.cleanup?.audit ?? [],
|
|
837
|
+
},
|
|
838
|
+
})
|
|
839
|
+
safeConsoleError('[dsh-task-board] acceptance-detail cleanup failed; it is retried on the next Host start', error)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Catch-up pass: clean the acceptance detail of every execution that PASSED
|
|
845
|
+
* and settled but whose cleanup did not finish (a crash, a failed write, a
|
|
846
|
+
* Host killed mid-cleanup).
|
|
847
|
+
*
|
|
848
|
+
* The same bounds as any other cleanup apply — only a real pass record on a
|
|
849
|
+
* settled execution is touched — so this can never delete material belonging
|
|
850
|
+
* to an execution that did not pass, and never a user file.
|
|
851
|
+
*/
|
|
852
|
+
private cleanupPendingAcceptanceDetail(): void {
|
|
853
|
+
for (const task of this.ledger.allTasks()) {
|
|
854
|
+
for (const execution of task.executions) {
|
|
855
|
+
if (execution.endedAt === undefined) continue
|
|
856
|
+
if (execution.result !== 'succeeded') continue
|
|
857
|
+
this.cleanupAcceptanceDetail(task.id, execution.id)
|
|
858
|
+
}
|
|
859
|
+
}
|
|
639
860
|
}
|
|
640
861
|
|
|
641
862
|
private noteUnreadableInspection(execution: OpenExecutionReference, reason: string | undefined): void {
|
|
@@ -717,10 +938,16 @@ export class TaskBoardHostService {
|
|
|
717
938
|
}
|
|
718
939
|
const before = this.statusMap()
|
|
719
940
|
for (const schedule of this.ledger.dueSchedules(now)) {
|
|
720
|
-
|
|
941
|
+
// The ledger owns both the budget gate and the next target: it refuses a
|
|
942
|
+
// rule that already spent its runs and stops a one-shot as it fires.
|
|
721
943
|
this.dispatchRuns(
|
|
722
|
-
this.ledger.openScheduled(schedule.taskId,
|
|
723
|
-
{
|
|
944
|
+
this.ledger.openScheduled(schedule.taskId, now),
|
|
945
|
+
{
|
|
946
|
+
triggeredAt: now,
|
|
947
|
+
timeZone: schedule.timeZone,
|
|
948
|
+
mode: schedule.mode,
|
|
949
|
+
...(schedule.cron === undefined ? {} : { cron: schedule.cron }),
|
|
950
|
+
},
|
|
724
951
|
)
|
|
725
952
|
}
|
|
726
953
|
this.emitStatusChanges(before)
|
|
@@ -769,11 +996,47 @@ export class TaskBoardHostService {
|
|
|
769
996
|
private schedulePoll(): void {
|
|
770
997
|
if (this.pollInFlight || this.disposed) return
|
|
771
998
|
this.pollInFlight = true
|
|
772
|
-
void this.pollSessions().
|
|
999
|
+
void this.pollSessions().then((rosterReadable) => {
|
|
1000
|
+
// A pass that could not read the roster is a failure even though it
|
|
1001
|
+
// resolved: the board knows no more about its sessions than before.
|
|
1002
|
+
if (rosterReadable) this.consecutivePollFailures = 0
|
|
1003
|
+
else this.notePollFailure()
|
|
1004
|
+
}).catch(error => {
|
|
1005
|
+
this.notePollFailure()
|
|
773
1006
|
safeConsoleError('[dsh-task-board] session polling failed', error)
|
|
774
1007
|
}).finally(() => { this.pollInFlight = false })
|
|
775
1008
|
}
|
|
776
1009
|
|
|
1010
|
+
/**
|
|
1011
|
+
* Count one failed pass and arm the retry that follows it.
|
|
1012
|
+
*
|
|
1013
|
+
* A session tree that is down is retried on a doubling delay instead of
|
|
1014
|
+
* hammering it on the fixed cadence: the heartbeat would otherwise repeat
|
|
1015
|
+
* every failure — and every retry inside {@link HostExecutionRunner.listRunning}
|
|
1016
|
+
* — at exactly the interval the reporter measured.
|
|
1017
|
+
*/
|
|
1018
|
+
private notePollFailure(): void {
|
|
1019
|
+
this.consecutivePollFailures += 1
|
|
1020
|
+
this.armPollBackoff()
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Arm the retry that follows a failed pass, doubling up to the ceiling. */
|
|
1024
|
+
private armPollBackoff(): void {
|
|
1025
|
+
if (this.disposed) return
|
|
1026
|
+
this.pollBackoffTimer?.()
|
|
1027
|
+
const delay = Math.min(sessionPollMs(this.sessionPollSeconds) * 2 ** Math.min(this.consecutivePollFailures, 8), POLL_FAILURE_MAX_BACKOFF_MS)
|
|
1028
|
+
this.pollBackoffTimer = this.timers.timeout(() => {
|
|
1029
|
+
this.pollBackoffTimer = undefined
|
|
1030
|
+
this.schedulePoll()
|
|
1031
|
+
}, delay)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Drop a pending retry; the next regular tick takes over. */
|
|
1035
|
+
private clearPollBackoff(): void {
|
|
1036
|
+
this.pollBackoffTimer?.()
|
|
1037
|
+
this.pollBackoffTimer = undefined
|
|
1038
|
+
}
|
|
1039
|
+
|
|
777
1040
|
/**
|
|
778
1041
|
* Re-arm from the ledger's current targets. Callers that just changed a
|
|
779
1042
|
* schedule (the host routes, the agent tools) invoke this after the write
|