@linxin666/dsh-client-ui-task-board 0.3.5 → 0.3.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/lib/client.js +260 -289
- package/lib/client.js.map +1 -1
- package/lib/index.js +29 -5
- package/lib/types/client/board/TaskForm.d.ts +28 -0
- package/lib/types/host-runner.d.ts +7 -0
- package/package.json +1 -1
- package/src/client/board/EditTaskModal.tsx +19 -55
- package/src/client/board/NewTaskModal.tsx +19 -54
- package/src/client/board/TaskForm.tsx +111 -0
- package/src/client/board-mount.tsx +7 -1
- package/src/core/controller.ts +1 -6
- package/src/host-runner.ts +28 -2
package/lib/index.js
CHANGED
|
@@ -1620,6 +1620,13 @@ function isErrorTurnEnd(data) {
|
|
|
1620
1620
|
var HostExecutionRunner = class {
|
|
1621
1621
|
api;
|
|
1622
1622
|
commands;
|
|
1623
|
+
/**
|
|
1624
|
+
* Newest scanned event seq per session whose complete backward scan found
|
|
1625
|
+
* no matching turn/end. While a session's newest seq is unchanged a
|
|
1626
|
+
* re-scan cannot change the outcome, so only a one-message probe runs per
|
|
1627
|
+
* poll tick instead of up to 100 history pages.
|
|
1628
|
+
*/
|
|
1629
|
+
scanMemos = /* @__PURE__ */ new Map();
|
|
1623
1630
|
constructor(api, commands) {
|
|
1624
1631
|
this.api = api;
|
|
1625
1632
|
this.commands = commands;
|
|
@@ -1696,11 +1703,24 @@ var HostExecutionRunner = class {
|
|
|
1696
1703
|
items = response.result.value.items;
|
|
1697
1704
|
}
|
|
1698
1705
|
const summary = items.find((item) => item.sessionId === sessionId);
|
|
1699
|
-
if (summary === void 0)
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1706
|
+
if (summary === void 0) {
|
|
1707
|
+
this.scanMemos.delete(sessionId);
|
|
1708
|
+
return {
|
|
1709
|
+
outcome: "cancelled",
|
|
1710
|
+
error: "execution session no longer exists"
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1703
1713
|
if (summary.running) return { outcome: "pending" };
|
|
1714
|
+
const probe = await this.api.sessions.history(request({
|
|
1715
|
+
sessionId: summary.sessionId,
|
|
1716
|
+
maxMessages: 1
|
|
1717
|
+
}));
|
|
1718
|
+
if (!probe.result.ok) return { outcome: "pending" };
|
|
1719
|
+
const newestSeq = probe.result.value.events.reduce((newest, entry) => {
|
|
1720
|
+
const seq = entry.event.seq;
|
|
1721
|
+
return typeof seq !== "number" ? newest : newest === void 0 ? seq : Math.max(newest, seq);
|
|
1722
|
+
}, void 0);
|
|
1723
|
+
if (newestSeq !== void 0 && this.scanMemos.get(sessionId) === newestSeq) return { outcome: "pending" };
|
|
1704
1724
|
const events = [];
|
|
1705
1725
|
let beforeSeq;
|
|
1706
1726
|
let reachedExecutionBoundary = false;
|
|
@@ -1729,7 +1749,11 @@ var HostExecutionRunner = class {
|
|
|
1729
1749
|
}
|
|
1730
1750
|
if (!reachedExecutionBoundary) return { outcome: "pending" };
|
|
1731
1751
|
const turnEnd = events.filter((entry) => entry.event.type === "turn/end" && (startedAt <= 0 || typeof entry.event.time === "number" && entry.event.time >= startedAt)).sort((a, b) => (a.event.seq ?? Number.MAX_SAFE_INTEGER) - (b.event.seq ?? Number.MAX_SAFE_INTEGER))[0];
|
|
1732
|
-
if (turnEnd === void 0)
|
|
1752
|
+
if (turnEnd === void 0) {
|
|
1753
|
+
if (newestSeq !== void 0) this.scanMemos.set(sessionId, newestSeq);
|
|
1754
|
+
return { outcome: "pending" };
|
|
1755
|
+
}
|
|
1756
|
+
this.scanMemos.delete(sessionId);
|
|
1733
1757
|
return isErrorTurnEnd(turnEnd.event.data) ? {
|
|
1734
1758
|
outcome: "failed",
|
|
1735
1759
|
error: "agent turn ended with an error"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared task-modal pieces: the overlay shell (backdrop, form, title, error,
|
|
3
|
+
* footer) and the title/description/prompt field trio used by both the
|
|
4
|
+
* NewTaskModal and the EditTaskModal. State stays in the owning modal; these
|
|
5
|
+
* are controlled components.
|
|
6
|
+
*/
|
|
7
|
+
import type { ReactNode } from 'react';
|
|
8
|
+
/** Modal overlay: closes on backdrop press, submits through the form. */
|
|
9
|
+
export declare function ModalShell({ ariaLabel, title, error, pending, submitLabel, onSubmit, onClose, children, }: {
|
|
10
|
+
ariaLabel: string;
|
|
11
|
+
title: string;
|
|
12
|
+
error: string | undefined;
|
|
13
|
+
pending: boolean;
|
|
14
|
+
submitLabel: string;
|
|
15
|
+
onSubmit: () => void;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}): import("react").JSX.Element;
|
|
19
|
+
/** Title + description + prompt fields shared by the new and edit task forms. */
|
|
20
|
+
export declare function TaskContentFields({ title, description, prompt, onTitleChange, onDescriptionChange, onPromptChange, }: {
|
|
21
|
+
title: string;
|
|
22
|
+
description: string;
|
|
23
|
+
prompt: string;
|
|
24
|
+
/** Receives the new title; the owner also clears its error state. */
|
|
25
|
+
onTitleChange: (value: string) => void;
|
|
26
|
+
onDescriptionChange: (value: string) => void;
|
|
27
|
+
onPromptChange: (value: string) => void;
|
|
28
|
+
}): import("react").JSX.Element;
|
|
@@ -30,6 +30,13 @@ export declare class SessionLaunchError extends Error {
|
|
|
30
30
|
export declare class HostExecutionRunner {
|
|
31
31
|
private readonly api;
|
|
32
32
|
private readonly commands?;
|
|
33
|
+
/**
|
|
34
|
+
* Newest scanned event seq per session whose complete backward scan found
|
|
35
|
+
* no matching turn/end. While a session's newest seq is unchanged a
|
|
36
|
+
* re-scan cannot change the outcome, so only a one-message probe runs per
|
|
37
|
+
* poll tick instead of up to 100 history pages.
|
|
38
|
+
*/
|
|
39
|
+
private readonly scanMemos;
|
|
33
40
|
constructor(api: ApiProxy, commands?: SessionCommandDispatcher | undefined);
|
|
34
41
|
launch(task: TaskRecord): Promise<string>;
|
|
35
42
|
listRunning(): Promise<{
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-client-ui-task-board",
|
|
3
3
|
"description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.6",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -9,7 +9,7 @@ import { useState } from 'react'
|
|
|
9
9
|
import type { BoardController } from '../../core/controller.ts'
|
|
10
10
|
import type { TaskRecord } from '../../core/tasks.ts'
|
|
11
11
|
import { t } from '../locales.ts'
|
|
12
|
-
import
|
|
12
|
+
import { ModalShell, TaskContentFields } from './TaskForm.tsx'
|
|
13
13
|
|
|
14
14
|
/** Edit-task form overlay. */
|
|
15
15
|
export function EditTaskModal({ controller, task, onClose }: { controller: BoardController; task: TaskRecord; onClose: () => void }) {
|
|
@@ -36,59 +36,23 @@ export function EditTaskModal({ controller, task, onClose }: { controller: Board
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
return (
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
</label>
|
|
58
|
-
|
|
59
|
-
<label className={css.field}>
|
|
60
|
-
<span className={css.fieldLabel}>{t('new.description')}</span>
|
|
61
|
-
<textarea
|
|
62
|
-
className={css.input}
|
|
63
|
-
rows={3}
|
|
64
|
-
value={description}
|
|
65
|
-
placeholder={t('new.descriptionPlaceholder')}
|
|
66
|
-
onChange={event => { setDescription(event.target.value) }}
|
|
67
|
-
/>
|
|
68
|
-
</label>
|
|
69
|
-
|
|
70
|
-
<label className={css.field}>
|
|
71
|
-
<span className={css.fieldLabel}>{t('new.prompt')}</span>
|
|
72
|
-
<textarea
|
|
73
|
-
className={css.input}
|
|
74
|
-
rows={4}
|
|
75
|
-
value={prompt}
|
|
76
|
-
placeholder={t('new.promptPlaceholder')}
|
|
77
|
-
onChange={event => { setPrompt(event.target.value) }}
|
|
78
|
-
/>
|
|
79
|
-
</label>
|
|
80
|
-
|
|
81
|
-
{error !== undefined && <p className={css.formError}>{error}</p>}
|
|
82
|
-
|
|
83
|
-
<footer className={css.modalFooter}>
|
|
84
|
-
<button type="button" className={css.ghostButton} onClick={onClose}>
|
|
85
|
-
{t('new.cancel')}
|
|
86
|
-
</button>
|
|
87
|
-
<button type="submit" className={css.primaryButton} disabled={pending}>
|
|
88
|
-
{t('edit.save')}
|
|
89
|
-
</button>
|
|
90
|
-
</footer>
|
|
91
|
-
</form>
|
|
92
|
-
</div>
|
|
39
|
+
<ModalShell
|
|
40
|
+
ariaLabel={t('edit.title')}
|
|
41
|
+
title={t('edit.title')}
|
|
42
|
+
error={error}
|
|
43
|
+
pending={pending}
|
|
44
|
+
submitLabel={t('edit.save')}
|
|
45
|
+
onSubmit={() => { void submit() }}
|
|
46
|
+
onClose={onClose}
|
|
47
|
+
>
|
|
48
|
+
<TaskContentFields
|
|
49
|
+
title={title}
|
|
50
|
+
description={description}
|
|
51
|
+
prompt={prompt}
|
|
52
|
+
onTitleChange={value => { setTitle(value); setError(undefined) }}
|
|
53
|
+
onDescriptionChange={setDescription}
|
|
54
|
+
onPromptChange={setPrompt}
|
|
55
|
+
/>
|
|
56
|
+
</ModalShell>
|
|
93
57
|
)
|
|
94
58
|
}
|
|
@@ -8,6 +8,7 @@ import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
|
|
|
8
8
|
import { TASK_PERMISSIONS, type TaskPermission } from '../../core/tasks.ts'
|
|
9
9
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
10
10
|
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
11
|
+
import { ModalShell, TaskContentFields } from './TaskForm.tsx'
|
|
11
12
|
import css from '../board.module.css'
|
|
12
13
|
|
|
13
14
|
/** New-task form overlay. */
|
|
@@ -64,47 +65,23 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
64
65
|
: undefined
|
|
65
66
|
|
|
66
67
|
return (
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
/>
|
|
85
|
-
</label>
|
|
86
|
-
|
|
87
|
-
<label className={css.field}>
|
|
88
|
-
<span className={css.fieldLabel}>{t('new.description')}</span>
|
|
89
|
-
<textarea
|
|
90
|
-
className={css.input}
|
|
91
|
-
rows={3}
|
|
92
|
-
value={description}
|
|
93
|
-
placeholder={t('new.descriptionPlaceholder')}
|
|
94
|
-
onChange={event => { setDescription(event.target.value) }}
|
|
95
|
-
/>
|
|
96
|
-
</label>
|
|
97
|
-
|
|
98
|
-
<label className={css.field}>
|
|
99
|
-
<span className={css.fieldLabel}>{t('new.prompt')}</span>
|
|
100
|
-
<textarea
|
|
101
|
-
className={css.input}
|
|
102
|
-
rows={4}
|
|
103
|
-
value={prompt}
|
|
104
|
-
placeholder={t('new.promptPlaceholder')}
|
|
105
|
-
onChange={event => { setPrompt(event.target.value) }}
|
|
106
|
-
/>
|
|
107
|
-
</label>
|
|
68
|
+
<ModalShell
|
|
69
|
+
ariaLabel={t('board.new')}
|
|
70
|
+
title={t('board.new')}
|
|
71
|
+
error={error}
|
|
72
|
+
pending={pending}
|
|
73
|
+
submitLabel={t('new.submit')}
|
|
74
|
+
onSubmit={() => { void submit() }}
|
|
75
|
+
onClose={onClose}
|
|
76
|
+
>
|
|
77
|
+
<TaskContentFields
|
|
78
|
+
title={title}
|
|
79
|
+
description={description}
|
|
80
|
+
prompt={prompt}
|
|
81
|
+
onTitleChange={value => { setTitle(value); setError(undefined) }}
|
|
82
|
+
onDescriptionChange={setDescription}
|
|
83
|
+
onPromptChange={setPrompt}
|
|
84
|
+
/>
|
|
108
85
|
|
|
109
86
|
<label className={css.field}>
|
|
110
87
|
<span className={css.fieldLabel}>{t('new.workspace')}</span>
|
|
@@ -201,18 +178,6 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
201
178
|
</>
|
|
202
179
|
)}
|
|
203
180
|
</section>
|
|
204
|
-
|
|
205
|
-
{error !== undefined && <p className={css.formError}>{error}</p>}
|
|
206
|
-
|
|
207
|
-
<footer className={css.modalFooter}>
|
|
208
|
-
<button type="button" className={css.ghostButton} onClick={onClose}>
|
|
209
|
-
{t('new.cancel')}
|
|
210
|
-
</button>
|
|
211
|
-
<button type="submit" className={css.primaryButton} disabled={pending}>
|
|
212
|
-
{t('new.submit')}
|
|
213
|
-
</button>
|
|
214
|
-
</footer>
|
|
215
|
-
</form>
|
|
216
|
-
</div>
|
|
181
|
+
</ModalShell>
|
|
217
182
|
)
|
|
218
183
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared task-modal pieces: the overlay shell (backdrop, form, title, error,
|
|
3
|
+
* footer) and the title/description/prompt field trio used by both the
|
|
4
|
+
* NewTaskModal and the EditTaskModal. State stays in the owning modal; these
|
|
5
|
+
* are controlled components.
|
|
6
|
+
*/
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import { t } from '../locales.ts'
|
|
9
|
+
import css from '../board.module.css'
|
|
10
|
+
|
|
11
|
+
/** Modal overlay: closes on backdrop press, submits through the form. */
|
|
12
|
+
export function ModalShell({
|
|
13
|
+
ariaLabel,
|
|
14
|
+
title,
|
|
15
|
+
error,
|
|
16
|
+
pending,
|
|
17
|
+
submitLabel,
|
|
18
|
+
onSubmit,
|
|
19
|
+
onClose,
|
|
20
|
+
children,
|
|
21
|
+
}: {
|
|
22
|
+
ariaLabel: string
|
|
23
|
+
title: string
|
|
24
|
+
error: string | undefined
|
|
25
|
+
pending: boolean
|
|
26
|
+
submitLabel: string
|
|
27
|
+
onSubmit: () => void
|
|
28
|
+
onClose: () => void
|
|
29
|
+
children: ReactNode
|
|
30
|
+
}) {
|
|
31
|
+
return (
|
|
32
|
+
<div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}>
|
|
33
|
+
<form
|
|
34
|
+
className={css.modal}
|
|
35
|
+
role="dialog"
|
|
36
|
+
aria-label={ariaLabel}
|
|
37
|
+
onSubmit={event => { event.preventDefault(); onSubmit() }}
|
|
38
|
+
>
|
|
39
|
+
<h2 className={css.modalTitle}>{title}</h2>
|
|
40
|
+
|
|
41
|
+
{children}
|
|
42
|
+
|
|
43
|
+
{error !== undefined && <p className={css.formError}>{error}</p>}
|
|
44
|
+
|
|
45
|
+
<footer className={css.modalFooter}>
|
|
46
|
+
<button type="button" className={css.ghostButton} onClick={onClose}>
|
|
47
|
+
{t('new.cancel')}
|
|
48
|
+
</button>
|
|
49
|
+
<button type="submit" className={css.primaryButton} disabled={pending}>
|
|
50
|
+
{submitLabel}
|
|
51
|
+
</button>
|
|
52
|
+
</footer>
|
|
53
|
+
</form>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Title + description + prompt fields shared by the new and edit task forms. */
|
|
59
|
+
export function TaskContentFields({
|
|
60
|
+
title,
|
|
61
|
+
description,
|
|
62
|
+
prompt,
|
|
63
|
+
onTitleChange,
|
|
64
|
+
onDescriptionChange,
|
|
65
|
+
onPromptChange,
|
|
66
|
+
}: {
|
|
67
|
+
title: string
|
|
68
|
+
description: string
|
|
69
|
+
prompt: string
|
|
70
|
+
/** Receives the new title; the owner also clears its error state. */
|
|
71
|
+
onTitleChange: (value: string) => void
|
|
72
|
+
onDescriptionChange: (value: string) => void
|
|
73
|
+
onPromptChange: (value: string) => void
|
|
74
|
+
}) {
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<label className={css.field}>
|
|
78
|
+
<span className={css.fieldLabel}>{t('new.title')}</span>
|
|
79
|
+
<input
|
|
80
|
+
className={css.input}
|
|
81
|
+
value={title}
|
|
82
|
+
autoFocus
|
|
83
|
+
placeholder={t('new.titlePlaceholder')}
|
|
84
|
+
onChange={event => onTitleChange(event.target.value)}
|
|
85
|
+
/>
|
|
86
|
+
</label>
|
|
87
|
+
|
|
88
|
+
<label className={css.field}>
|
|
89
|
+
<span className={css.fieldLabel}>{t('new.description')}</span>
|
|
90
|
+
<textarea
|
|
91
|
+
className={css.input}
|
|
92
|
+
rows={3}
|
|
93
|
+
value={description}
|
|
94
|
+
placeholder={t('new.descriptionPlaceholder')}
|
|
95
|
+
onChange={event => onDescriptionChange(event.target.value)}
|
|
96
|
+
/>
|
|
97
|
+
</label>
|
|
98
|
+
|
|
99
|
+
<label className={css.field}>
|
|
100
|
+
<span className={css.fieldLabel}>{t('new.prompt')}</span>
|
|
101
|
+
<textarea
|
|
102
|
+
className={css.input}
|
|
103
|
+
rows={4}
|
|
104
|
+
value={prompt}
|
|
105
|
+
placeholder={t('new.promptPlaceholder')}
|
|
106
|
+
onChange={event => onPromptChange(event.target.value)}
|
|
107
|
+
/>
|
|
108
|
+
</label>
|
|
109
|
+
</>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -44,7 +44,13 @@ export function mountBoard(controller: BoardController): () => void {
|
|
|
44
44
|
let container: HTMLDivElement | undefined
|
|
45
45
|
|
|
46
46
|
const ensure = (): void => {
|
|
47
|
-
if (container !== undefined)
|
|
47
|
+
if (container !== undefined) {
|
|
48
|
+
if (container.isConnected) return
|
|
49
|
+
root?.unmount()
|
|
50
|
+
root = undefined
|
|
51
|
+
container.remove()
|
|
52
|
+
container = undefined
|
|
53
|
+
}
|
|
48
54
|
const column = conversationColumn()
|
|
49
55
|
if (column === undefined) return
|
|
50
56
|
container = document.createElement('div')
|
package/src/core/controller.ts
CHANGED
|
@@ -200,17 +200,11 @@ export class BoardController {
|
|
|
200
200
|
|
|
201
201
|
openBoard(): void {
|
|
202
202
|
if (this.boardOpen) return
|
|
203
|
-
// The board stays open until the user explicitly closes it: by clicking
|
|
204
|
-
// the sidebar entry again, a sidebar session/workspace row, or a board
|
|
205
|
-
// action (openSession / close). Session-list churn (background
|
|
206
|
-
// navigation, the Host runner selecting a fresh execution session,
|
|
207
|
-
// settlement, other plugins) never evicts the board.
|
|
208
203
|
this.boardOpen = true
|
|
209
204
|
this.notify()
|
|
210
205
|
}
|
|
211
206
|
|
|
212
207
|
closeBoard(): void {
|
|
213
|
-
if (!this.boardOpen) return
|
|
214
208
|
this.boardOpen = false
|
|
215
209
|
this.notify()
|
|
216
210
|
}
|
|
@@ -396,6 +390,7 @@ export class BoardController {
|
|
|
396
390
|
* @param sessionId - the execution session to open.
|
|
397
391
|
*/
|
|
398
392
|
openSession(sessionId: string): void {
|
|
393
|
+
this.closeBoard()
|
|
399
394
|
this.deps.sessions.open(sessionId)
|
|
400
395
|
}
|
|
401
396
|
|
package/src/host-runner.ts
CHANGED
|
@@ -50,6 +50,14 @@ function isErrorTurnEnd(data: unknown): boolean {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export class HostExecutionRunner {
|
|
53
|
+
/**
|
|
54
|
+
* Newest scanned event seq per session whose complete backward scan found
|
|
55
|
+
* no matching turn/end. While a session's newest seq is unchanged a
|
|
56
|
+
* re-scan cannot change the outcome, so only a one-message probe runs per
|
|
57
|
+
* poll tick instead of up to 100 history pages.
|
|
58
|
+
*/
|
|
59
|
+
private readonly scanMemos = new Map<string, number>()
|
|
60
|
+
|
|
53
61
|
constructor(
|
|
54
62
|
private readonly api: ApiProxy,
|
|
55
63
|
private readonly commands?: SessionCommandDispatcher,
|
|
@@ -124,8 +132,21 @@ export class HostExecutionRunner {
|
|
|
124
132
|
items = response.result.value.items
|
|
125
133
|
}
|
|
126
134
|
const summary = items.find(item => item.sessionId === sessionId)
|
|
127
|
-
if (summary === undefined)
|
|
135
|
+
if (summary === undefined) {
|
|
136
|
+
this.scanMemos.delete(sessionId)
|
|
137
|
+
return { outcome: 'cancelled', error: 'execution session no longer exists' }
|
|
138
|
+
}
|
|
128
139
|
if (summary.running) return { outcome: 'pending' }
|
|
140
|
+
// Probe the newest event before paging: SessionSummary.updatedAt tracks
|
|
141
|
+
// human prompts, not event appends, so only the history head proves
|
|
142
|
+
// whether a re-scan could find anything new.
|
|
143
|
+
const probe = await this.api.sessions.history(request({ sessionId: summary.sessionId, maxMessages: 1 }))
|
|
144
|
+
if (!probe.result.ok) return { outcome: 'pending' }
|
|
145
|
+
const newestSeq = probe.result.value.events.reduce<number | undefined>((newest, entry) => {
|
|
146
|
+
const seq = entry.event.seq
|
|
147
|
+
return typeof seq !== 'number' ? newest : newest === undefined ? seq : Math.max(newest, seq)
|
|
148
|
+
}, undefined)
|
|
149
|
+
if (newestSeq !== undefined && this.scanMemos.get(sessionId) === newestSeq) return { outcome: 'pending' }
|
|
129
150
|
const events: Array<{ event: { type: string; seq?: number; time?: number; data: unknown } }> = []
|
|
130
151
|
let beforeSeq: number | undefined
|
|
131
152
|
let reachedExecutionBoundary = false
|
|
@@ -158,7 +179,12 @@ export class HostExecutionRunner {
|
|
|
158
179
|
startedAt <= 0 || (typeof entry.event.time === 'number' && entry.event.time >= startedAt)
|
|
159
180
|
))
|
|
160
181
|
.sort((a, b) => (a.event.seq ?? Number.MAX_SAFE_INTEGER) - (b.event.seq ?? Number.MAX_SAFE_INTEGER))[0]
|
|
161
|
-
if (turnEnd === undefined)
|
|
182
|
+
if (turnEnd === undefined) {
|
|
183
|
+
// Complete scan, no match: remember the head so later ticks probe only.
|
|
184
|
+
if (newestSeq !== undefined) this.scanMemos.set(sessionId, newestSeq)
|
|
185
|
+
return { outcome: 'pending' }
|
|
186
|
+
}
|
|
187
|
+
this.scanMemos.delete(sessionId)
|
|
162
188
|
return isErrorTurnEnd(turnEnd.event.data)
|
|
163
189
|
? { outcome: 'failed', error: 'agent turn ended with an error' }
|
|
164
190
|
: { outcome: 'succeeded' }
|