@linxin666/dsh-client-ui-task-board 0.3.17 → 0.3.19
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 +3 -2
- package/README.zh.md +3 -2
- package/lib/client.js +100 -14
- package/lib/client.js.map +1 -1
- package/lib/index.js +143 -39
- package/lib/types/client/board/NewTaskModal.d.ts +9 -3
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/core/session-reuse.d.ts +22 -0
- package/lib/types/core/tasks.d.ts +10 -0
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +2 -0
- package/lib/types/host-runner.d.ts +19 -1
- package/lib/types/host-service.d.ts +18 -0
- package/package.json +1 -1
- package/src/client/board/NewTaskModal.tsx +58 -14
- package/src/client/board/TaskDetail.tsx +39 -1
- package/src/client/locales.ts +12 -0
- package/src/core/session-reuse.ts +31 -0
- package/src/core/store.ts +2 -0
- package/src/core/tasks.ts +11 -0
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host-ledger.ts +30 -2
- package/src/host-runner.ts +59 -29
- package/src/host-service.ts +46 -4
- package/src/protocol.ts +6 -2
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import z from "schemastery";
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
4
|
-
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { dirname, isAbsolute, join, win32 } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
|
|
@@ -174,6 +174,29 @@ function isDigits(value) {
|
|
|
174
174
|
return /^\d+$/.test(value);
|
|
175
175
|
}
|
|
176
176
|
//#endregion
|
|
177
|
+
//#region src/core/session-reuse.ts
|
|
178
|
+
/**
|
|
179
|
+
* Pick the session a new execution of this task may continue in, or undefined
|
|
180
|
+
* to mint a fresh conversation. Reuse requires ALL of:
|
|
181
|
+
* - the task opted in (`reuseSession === true`);
|
|
182
|
+
* - the task's newest execution carries a session id and has settled (an open
|
|
183
|
+
* execution would have made the ledger refuse the run in the first place);
|
|
184
|
+
* - the roster is known and that session is present and idle.
|
|
185
|
+
*
|
|
186
|
+
* An unknown roster (session/list unavailable) never reuses: minting a fresh
|
|
187
|
+
* conversation is always safe, prompting into a session we cannot see is not.
|
|
188
|
+
* @param task - the task about to run.
|
|
189
|
+
* @param idleSessionIds - ids the last roster saw as present and not running;
|
|
190
|
+
* undefined when that roster is unknown.
|
|
191
|
+
* @returns the session id to continue in, or undefined for a fresh session.
|
|
192
|
+
*/
|
|
193
|
+
function reusableSessionId(task, idleSessionIds) {
|
|
194
|
+
if (task.reuseSession !== true || idleSessionIds === void 0) return void 0;
|
|
195
|
+
const last = task.executions[task.executions.length - 1];
|
|
196
|
+
if (last === void 0 || last.sessionId === void 0 || last.endedAt === void 0) return void 0;
|
|
197
|
+
return idleSessionIds.has(last.sessionId) ? last.sessionId : void 0;
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
177
200
|
//#region src/dsh-home.ts
|
|
178
201
|
/**
|
|
179
202
|
* DSH_HOME resolution shared by the plugin family's Host halves: the
|
|
@@ -285,6 +308,7 @@ function createTask(input, now, id) {
|
|
|
285
308
|
mode: normalizeTargetId(input.mode),
|
|
286
309
|
permission: isTaskPermission(input.permission) ? input.permission : void 0,
|
|
287
310
|
model: normalizeTargetId(input.model),
|
|
311
|
+
reuseSession: input.reuseSession === true ? true : void 0,
|
|
288
312
|
...input.freeze === void 0 ? {} : { freeze: freezeOf(input.freeze, now) },
|
|
289
313
|
...input.handover === void 0 ? {} : { handover: {
|
|
290
314
|
...input.handover,
|
|
@@ -582,6 +606,7 @@ function isTaskRecordShape(value) {
|
|
|
582
606
|
if (record.workspaceId !== void 0 && typeof record.workspaceId !== "string") return false;
|
|
583
607
|
if (record.mode !== void 0 && typeof record.mode !== "string") return false;
|
|
584
608
|
if (record.permission !== void 0 && typeof record.permission !== "string") return false;
|
|
609
|
+
if (record.reuseSession !== void 0 && typeof record.reuseSession !== "boolean") return false;
|
|
585
610
|
if (!Array.isArray(record.executions)) return false;
|
|
586
611
|
for (const execution of record.executions) {
|
|
587
612
|
if (typeof execution !== "object" || execution === null) return false;
|
|
@@ -695,6 +720,7 @@ function parseLedger(raw) {
|
|
|
695
720
|
task.mode = normalizeTargetId(row.mode);
|
|
696
721
|
task.archivedAt = typeof row.archivedAt === "number" && Number.isFinite(row.archivedAt) ? row.archivedAt : void 0;
|
|
697
722
|
task.permission = isTaskPermission(row.permission) ? row.permission : void 0;
|
|
723
|
+
task.reuseSession = row.reuseSession === true ? true : void 0;
|
|
698
724
|
task.freeze = normalizeFreeze(row.freeze);
|
|
699
725
|
task.handover = normalizeHandover(row.handover);
|
|
700
726
|
task.permissionConfirmedAt = typeof row.permissionConfirmedAt === "number" && Number.isFinite(row.permissionConfirmedAt) ? row.permissionConfirmedAt : void 0;
|
|
@@ -929,6 +955,7 @@ function applyUpdateTask(tasks, id, patch, now) {
|
|
|
929
955
|
bundledAt: now
|
|
930
956
|
};
|
|
931
957
|
if ("permission" in patch && patch.permission !== void 0 && patch.permission !== task.permission || "handover" in patch) next.permissionConfirmedAt = void 0;
|
|
958
|
+
if ("reuseSession" in patch) next.reuseSession = patch.reuseSession === true ? true : void 0;
|
|
932
959
|
if (workspaceId !== void 0 || "workspaceId" in patch) next.workspaceId = workspaceId;
|
|
933
960
|
if (mode !== void 0 || "mode" in patch) next.mode = mode;
|
|
934
961
|
if (permission !== void 0 || "permission" in patch) next.permission = permission;
|
|
@@ -1022,6 +1049,7 @@ function importedTask(value) {
|
|
|
1022
1049
|
...task.workspaceId === void 0 ? {} : { workspaceId: task.workspaceId },
|
|
1023
1050
|
...task.mode === void 0 ? {} : { mode: task.mode },
|
|
1024
1051
|
...task.permission === void 0 ? {} : { permission: task.permission },
|
|
1052
|
+
...task.reuseSession === void 0 ? {} : { reuseSession: task.reuseSession },
|
|
1025
1053
|
...task.archivedAt === void 0 ? {} : { archivedAt: task.archivedAt },
|
|
1026
1054
|
...task.freeze === void 0 ? {} : { freeze: task.freeze },
|
|
1027
1055
|
...task.handover === void 0 ? {} : { handover: task.handover }
|
|
@@ -1063,10 +1091,12 @@ function createInput(value) {
|
|
|
1063
1091
|
"schedule",
|
|
1064
1092
|
"freeze",
|
|
1065
1093
|
"handover",
|
|
1066
|
-
"model"
|
|
1094
|
+
"model",
|
|
1095
|
+
"reuseSession"
|
|
1067
1096
|
])) return false;
|
|
1068
1097
|
if (typeof input.title !== "string" || typeof input.description !== "string" || typeof input.prompt !== "string") return false;
|
|
1069
1098
|
if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false;
|
|
1099
|
+
if (input.reuseSession !== void 0 && typeof input.reuseSession !== "boolean") return false;
|
|
1070
1100
|
if (input.permission !== void 0 && !isTaskPermission(input.permission)) return false;
|
|
1071
1101
|
if (input.freeze !== void 0 && freezePayload(input.freeze) === void 0) return false;
|
|
1072
1102
|
if (input.handover !== void 0 && handoverPayload(input.handover) === void 0) return false;
|
|
@@ -1088,8 +1118,10 @@ function updatePatch(value) {
|
|
|
1088
1118
|
"permission",
|
|
1089
1119
|
"freeze",
|
|
1090
1120
|
"handover",
|
|
1091
|
-
"model"
|
|
1121
|
+
"model",
|
|
1122
|
+
"reuseSession"
|
|
1092
1123
|
])) return false;
|
|
1124
|
+
if (patch.reuseSession !== void 0 && patch.reuseSession !== null && typeof patch.reuseSession !== "boolean") return false;
|
|
1093
1125
|
for (const key of [
|
|
1094
1126
|
"title",
|
|
1095
1127
|
"description",
|
|
@@ -1455,6 +1487,7 @@ var HostTaskLedger = class {
|
|
|
1455
1487
|
this.file = join(dir, "ledger-v2.json");
|
|
1456
1488
|
this.lockFile = join(dir, "ledger-v2.lock");
|
|
1457
1489
|
this.schedulerFile = join(dir, "scheduler-v2.json");
|
|
1490
|
+
this.cleanStaleTemporaryFiles(dir);
|
|
1458
1491
|
this.lockFd = this.acquireLock();
|
|
1459
1492
|
try {
|
|
1460
1493
|
this.document = this.load(dir);
|
|
@@ -1467,6 +1500,15 @@ var HostTaskLedger = class {
|
|
|
1467
1500
|
throw error;
|
|
1468
1501
|
}
|
|
1469
1502
|
}
|
|
1503
|
+
/** Remove leftover *.tmp-* files from previous crashes or interrupted writes. */
|
|
1504
|
+
cleanStaleTemporaryFiles(dir) {
|
|
1505
|
+
try {
|
|
1506
|
+
const entries = readdirSync(dir);
|
|
1507
|
+
for (const entry of entries) if (entry.includes(".tmp-")) try {
|
|
1508
|
+
unlinkSync(join(dir, entry));
|
|
1509
|
+
} catch {}
|
|
1510
|
+
} catch {}
|
|
1511
|
+
}
|
|
1470
1512
|
/** Revision + scheduler without any task cloning; feeds the SSE event frame. */
|
|
1471
1513
|
summary() {
|
|
1472
1514
|
const { importedSources: _imports, ...scheduler } = this.document.scheduler;
|
|
@@ -1604,7 +1646,12 @@ var HostTaskLedger = class {
|
|
|
1604
1646
|
...patch
|
|
1605
1647
|
};
|
|
1606
1648
|
if (patch.lastTickAt !== void 0 && Object.keys(patch).every((key) => key === "lastTickAt")) {
|
|
1607
|
-
|
|
1649
|
+
try {
|
|
1650
|
+
this.writeSchedulerSidecar();
|
|
1651
|
+
} catch (error) {
|
|
1652
|
+
if (error?.code === "ENOSPC") return;
|
|
1653
|
+
throw error;
|
|
1654
|
+
}
|
|
1608
1655
|
return;
|
|
1609
1656
|
}
|
|
1610
1657
|
this.commit(false);
|
|
@@ -2128,7 +2175,17 @@ var HostExecutionRunner = class {
|
|
|
2128
2175
|
...signal === void 0 ? {} : { signal }
|
|
2129
2176
|
});
|
|
2130
2177
|
}
|
|
2131
|
-
|
|
2178
|
+
/**
|
|
2179
|
+
* Launch one execution. Without `options.reuseSessionId` a fresh session is
|
|
2180
|
+
* created, renamed, pinned, and prompted (the historical contract). With it,
|
|
2181
|
+
* the run continues in that existing session (issue #1419): the conversation
|
|
2182
|
+
* keeps its title and history, the pinned permission/model are re-asserted so
|
|
2183
|
+
* the task's execution contract still holds, and the prompt is queued.
|
|
2184
|
+
* @param task - the task to run.
|
|
2185
|
+
* @param options - optional session to continue in.
|
|
2186
|
+
* @returns the session id the execution runs in.
|
|
2187
|
+
*/
|
|
2188
|
+
async launch(task, options = {}) {
|
|
2132
2189
|
const workspaceId = task.handover?.workspaceId ?? task.workspaceId;
|
|
2133
2190
|
const mode = task.handover?.mode ?? task.mode;
|
|
2134
2191
|
const permission = task.handover?.permission ?? task.permission;
|
|
@@ -2140,6 +2197,15 @@ var HostExecutionRunner = class {
|
|
|
2140
2197
|
if (preset === void 0) throw new Error("agent preset not found: " + mode);
|
|
2141
2198
|
if (preset.broken !== void 0) throw new Error("agent preset is unavailable: " + preset.broken);
|
|
2142
2199
|
}
|
|
2200
|
+
const reused = options.reuseSessionId;
|
|
2201
|
+
if (reused !== void 0) {
|
|
2202
|
+
try {
|
|
2203
|
+
await this.pinAndPrompt(reused, task, permission);
|
|
2204
|
+
} catch (error) {
|
|
2205
|
+
throw new SessionLaunchError(reused, error);
|
|
2206
|
+
}
|
|
2207
|
+
return reused;
|
|
2208
|
+
}
|
|
2143
2209
|
const sessionId = (await this.invoke("session", "create", {
|
|
2144
2210
|
...workspaceId === void 0 ? {} : { workspaceId },
|
|
2145
2211
|
...mode === void 0 ? {} : { agentPreset: mode }
|
|
@@ -2149,41 +2215,49 @@ var HostExecutionRunner = class {
|
|
|
2149
2215
|
sessionId,
|
|
2150
2216
|
title: task.title
|
|
2151
2217
|
});
|
|
2152
|
-
|
|
2153
|
-
if (this.commands === void 0) throw new Error("permission command dispatcher is unavailable");
|
|
2154
|
-
const command = await this.commands.execute(sessionId, "/permission " + permission, AbortSignal.timeout(3e4));
|
|
2155
|
-
if (command === void 0) throw new Error("permission command was not acknowledged");
|
|
2156
|
-
if (command.kind !== "success") throw new Error(command.text ?? "permission command failed");
|
|
2157
|
-
}
|
|
2158
|
-
if (task.model !== void 0 && task.model.trim() !== "") {
|
|
2159
|
-
const rawModel = task.model.trim();
|
|
2160
|
-
const slashIdx = rawModel.indexOf("/");
|
|
2161
|
-
const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : void 0;
|
|
2162
|
-
const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel;
|
|
2163
|
-
try {
|
|
2164
|
-
await this.invoke("session", "selectModel", {
|
|
2165
|
-
sessionId,
|
|
2166
|
-
...provider ? { provider } : {},
|
|
2167
|
-
model: modelId
|
|
2168
|
-
});
|
|
2169
|
-
} catch (modelError) {
|
|
2170
|
-
console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError);
|
|
2171
|
-
}
|
|
2172
|
-
}
|
|
2173
|
-
await this.invoke("session", "prompt", {
|
|
2174
|
-
sessionId,
|
|
2175
|
-
requestId: "task-board-" + crypto.randomUUID(),
|
|
2176
|
-
mode: "queue",
|
|
2177
|
-
content: [{
|
|
2178
|
-
type: "text",
|
|
2179
|
-
text: promptText(task)
|
|
2180
|
-
}]
|
|
2181
|
-
});
|
|
2218
|
+
await this.pinAndPrompt(sessionId, task, permission);
|
|
2182
2219
|
} catch (error) {
|
|
2183
2220
|
throw new SessionLaunchError(sessionId, error);
|
|
2184
2221
|
}
|
|
2185
2222
|
return sessionId;
|
|
2186
2223
|
}
|
|
2224
|
+
/**
|
|
2225
|
+
* Re-assert the pinned execution contract on a session and queue the task
|
|
2226
|
+
* prompt. Shared by the fresh-session and reuse paths so both apply exactly
|
|
2227
|
+
* the same permission/model pins before the prompt.
|
|
2228
|
+
*/
|
|
2229
|
+
async pinAndPrompt(sessionId, task, permission) {
|
|
2230
|
+
if (permission !== void 0) {
|
|
2231
|
+
if (this.commands === void 0) throw new Error("permission command dispatcher is unavailable");
|
|
2232
|
+
const command = await this.commands.execute(sessionId, "/permission " + permission, AbortSignal.timeout(3e4));
|
|
2233
|
+
if (command === void 0) throw new Error("permission command was not acknowledged");
|
|
2234
|
+
if (command.kind !== "success") throw new Error(command.text ?? "permission command failed");
|
|
2235
|
+
}
|
|
2236
|
+
if (task.model !== void 0 && task.model.trim() !== "") {
|
|
2237
|
+
const rawModel = task.model.trim();
|
|
2238
|
+
const slashIdx = rawModel.indexOf("/");
|
|
2239
|
+
const provider = slashIdx >= 0 ? rawModel.slice(0, slashIdx).trim() : void 0;
|
|
2240
|
+
const modelId = slashIdx >= 0 ? rawModel.slice(slashIdx + 1).trim() : rawModel;
|
|
2241
|
+
try {
|
|
2242
|
+
await this.invoke("session", "selectModel", {
|
|
2243
|
+
sessionId,
|
|
2244
|
+
...provider ? { provider } : {},
|
|
2245
|
+
model: modelId
|
|
2246
|
+
});
|
|
2247
|
+
} catch (modelError) {
|
|
2248
|
+
console.warn(`[dsh-task-board] failed to select model "${task.model}" for session ${sessionId}, falling back to default:`, modelError);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
await this.invoke("session", "prompt", {
|
|
2252
|
+
sessionId,
|
|
2253
|
+
requestId: "task-board-" + crypto.randomUUID(),
|
|
2254
|
+
mode: "queue",
|
|
2255
|
+
content: [{
|
|
2256
|
+
type: "text",
|
|
2257
|
+
text: promptText(task)
|
|
2258
|
+
}]
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2187
2261
|
async listRunning() {
|
|
2188
2262
|
for (let attempt = 1;; attempt++) try {
|
|
2189
2263
|
const response = await this.invoke("session", "list", {});
|
|
@@ -2617,6 +2691,13 @@ var TaskBoardHostService = class {
|
|
|
2617
2691
|
pollInFlight = false;
|
|
2618
2692
|
tickInFlight = false;
|
|
2619
2693
|
active = true;
|
|
2694
|
+
/**
|
|
2695
|
+
* Ids the last roster poll saw as present and idle; undefined while the
|
|
2696
|
+
* roster is unknown. Session reuse (issue #1419) requires this positive
|
|
2697
|
+
* evidence, so a launch before the first successful poll mints a fresh
|
|
2698
|
+
* conversation instead of prompting into a session it cannot see.
|
|
2699
|
+
*/
|
|
2700
|
+
idleSessionIds;
|
|
2620
2701
|
preventIdleSleep = false;
|
|
2621
2702
|
lastPowerJson = "";
|
|
2622
2703
|
now;
|
|
@@ -2625,6 +2706,7 @@ var TaskBoardHostService = class {
|
|
|
2625
2706
|
this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry);
|
|
2626
2707
|
this.power = options.power ?? new PowerInhibitor();
|
|
2627
2708
|
this.now = options.now ?? Date.now;
|
|
2709
|
+
installStreamErrorGuards();
|
|
2628
2710
|
this.ledger.subscribe(() => {
|
|
2629
2711
|
this.syncPowerReasons();
|
|
2630
2712
|
this.emit();
|
|
@@ -2714,7 +2796,8 @@ var TaskBoardHostService = class {
|
|
|
2714
2796
|
}
|
|
2715
2797
|
async launch(opened) {
|
|
2716
2798
|
try {
|
|
2717
|
-
const
|
|
2799
|
+
const reuseSessionId = reusableSessionId(opened.task, this.idleSessionIds);
|
|
2800
|
+
const sessionId = await this.runner.launch(opened.task, reuseSessionId === void 0 ? {} : { reuseSessionId });
|
|
2718
2801
|
this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId);
|
|
2719
2802
|
} catch (error) {
|
|
2720
2803
|
if (error instanceof SessionLaunchError) this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId);
|
|
@@ -2727,6 +2810,7 @@ var TaskBoardHostService = class {
|
|
|
2727
2810
|
const running = await this.runner.listRunning();
|
|
2728
2811
|
const previous = this.power.snapshot();
|
|
2729
2812
|
if (!running.known) {
|
|
2813
|
+
this.idleSessionIds = void 0;
|
|
2730
2814
|
this.power.updateReasons({
|
|
2731
2815
|
runningSessions: previous.runningSessions,
|
|
2732
2816
|
armedSchedules: this.ledger.armedScheduleCount(),
|
|
@@ -2734,6 +2818,7 @@ var TaskBoardHostService = class {
|
|
|
2734
2818
|
});
|
|
2735
2819
|
return;
|
|
2736
2820
|
}
|
|
2821
|
+
this.idleSessionIds = new Set(running.items.filter((item) => !item.running).map((item) => item.sessionId));
|
|
2737
2822
|
const runtime = this.ledger.runtimeView();
|
|
2738
2823
|
this.power.updateReasons({
|
|
2739
2824
|
runningSessions: running.count,
|
|
@@ -2774,14 +2859,14 @@ var TaskBoardHostService = class {
|
|
|
2774
2859
|
}
|
|
2775
2860
|
scheduleLaunch(opened) {
|
|
2776
2861
|
this.launch(opened).catch((error) => {
|
|
2777
|
-
|
|
2862
|
+
safeConsoleError("[dsh-task-board] execution launch settlement failed", error);
|
|
2778
2863
|
});
|
|
2779
2864
|
}
|
|
2780
2865
|
schedulePoll() {
|
|
2781
2866
|
if (this.pollInFlight || this.disposed) return;
|
|
2782
2867
|
this.pollInFlight = true;
|
|
2783
2868
|
this.pollSessions().catch((error) => {
|
|
2784
|
-
|
|
2869
|
+
safeConsoleError("[dsh-task-board] session polling failed", error);
|
|
2785
2870
|
}).finally(() => {
|
|
2786
2871
|
this.pollInFlight = false;
|
|
2787
2872
|
});
|
|
@@ -2790,7 +2875,7 @@ var TaskBoardHostService = class {
|
|
|
2790
2875
|
if (this.tickInFlight || this.disposed) return;
|
|
2791
2876
|
this.tickInFlight = true;
|
|
2792
2877
|
this.tickSchedule(first).catch((error) => {
|
|
2793
|
-
|
|
2878
|
+
safeConsoleError("[dsh-task-board] scheduler tick failed", error);
|
|
2794
2879
|
}).finally(() => {
|
|
2795
2880
|
this.tickInFlight = false;
|
|
2796
2881
|
});
|
|
@@ -2808,6 +2893,25 @@ var TaskBoardHostService = class {
|
|
|
2808
2893
|
for (const listener of [...this.listeners]) listener();
|
|
2809
2894
|
}
|
|
2810
2895
|
};
|
|
2896
|
+
/**
|
|
2897
|
+
* Install stream error listeners on process.stderr and process.stdout so that
|
|
2898
|
+
* transient write failures (e.g. ENOSPC when the disk is full, or EPIPE on a
|
|
2899
|
+
* closed pipe) never emit unhandled 'error' events that kill the Node.js host process.
|
|
2900
|
+
*/
|
|
2901
|
+
function installStreamErrorGuards() {
|
|
2902
|
+
for (const stream of [process.stderr, process.stdout]) if (stream && typeof stream.on === "function") {
|
|
2903
|
+
if (!(typeof stream.listenerCount === "function" && stream.listenerCount("error") > 0)) stream.on("error", () => {});
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
/**
|
|
2907
|
+
* Defensively log to console.error without letting stderr write failures
|
|
2908
|
+
* (e.g. ENOSPC from SyncWriteStream on redirected logs) crash the host process.
|
|
2909
|
+
*/
|
|
2910
|
+
function safeConsoleError(message, ...args) {
|
|
2911
|
+
try {
|
|
2912
|
+
console.error(message, ...args);
|
|
2913
|
+
} catch {}
|
|
2914
|
+
}
|
|
2811
2915
|
//#endregion
|
|
2812
2916
|
//#region src/http.ts
|
|
2813
2917
|
/** Family-default JSON response headers; callers may append or override. */
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { BoardController } from '../../core/controller.ts';
|
|
2
|
-
|
|
3
|
-
export
|
|
2
|
+
import { type TaskRecord } from '../../core/tasks.ts';
|
|
3
|
+
export interface NewTaskModalProps {
|
|
4
4
|
controller: BoardController;
|
|
5
5
|
onClose: () => void;
|
|
6
|
-
|
|
6
|
+
/** Optional task template to clone/duplicate from. */
|
|
7
|
+
initialTask?: TaskRecord;
|
|
8
|
+
/** Optional callback after successful duplication (e.g. to archive source). */
|
|
9
|
+
onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/** New-task form overlay. */
|
|
12
|
+
export declare function NewTaskModal({ controller, onClose, initialTask, onDuplicateSuccess }: NewTaskModalProps): import("react").JSX.Element;
|
|
@@ -59,9 +59,13 @@ export declare const zh: {
|
|
|
59
59
|
'detail.permissionConfirmed': string;
|
|
60
60
|
'detail.title': string;
|
|
61
61
|
'detail.edit': string;
|
|
62
|
+
'detail.duplicate': string;
|
|
63
|
+
'detail.duplicateAndEdit': string;
|
|
62
64
|
'detail.close': string;
|
|
63
65
|
'edit.title': string;
|
|
64
66
|
'edit.save': string;
|
|
67
|
+
'new.duplicateTitle': string;
|
|
68
|
+
'new.archiveOriginal': string;
|
|
65
69
|
'detail.prompt': string;
|
|
66
70
|
'detail.description': string;
|
|
67
71
|
'detail.execution': string;
|
|
@@ -120,6 +124,8 @@ export declare const zh: {
|
|
|
120
124
|
'exec.permission.danger-full-access': string;
|
|
121
125
|
'exec.model.default': string;
|
|
122
126
|
'exec.model.unknown': string;
|
|
127
|
+
'exec.reuseSession': string;
|
|
128
|
+
'exec.reuseSessionHint': string;
|
|
123
129
|
'detail.executionSettings': string;
|
|
124
130
|
'exec.hint': string;
|
|
125
131
|
'settings.title': string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-reuse rule (issue #1419): decides which task runs continue in the
|
|
3
|
+
* previous execution's conversation. Pure, so the fail-closed conditions stay
|
|
4
|
+
* unit-testable without a gateway.
|
|
5
|
+
*/
|
|
6
|
+
import type { TaskRecord } from './tasks.ts';
|
|
7
|
+
/**
|
|
8
|
+
* Pick the session a new execution of this task may continue in, or undefined
|
|
9
|
+
* to mint a fresh conversation. Reuse requires ALL of:
|
|
10
|
+
* - the task opted in (`reuseSession === true`);
|
|
11
|
+
* - the task's newest execution carries a session id and has settled (an open
|
|
12
|
+
* execution would have made the ledger refuse the run in the first place);
|
|
13
|
+
* - the roster is known and that session is present and idle.
|
|
14
|
+
*
|
|
15
|
+
* An unknown roster (session/list unavailable) never reuses: minting a fresh
|
|
16
|
+
* conversation is always safe, prompting into a session we cannot see is not.
|
|
17
|
+
* @param task - the task about to run.
|
|
18
|
+
* @param idleSessionIds - ids the last roster saw as present and not running;
|
|
19
|
+
* undefined when that roster is unknown.
|
|
20
|
+
* @returns the session id to continue in, or undefined for a fresh session.
|
|
21
|
+
*/
|
|
22
|
+
export declare function reusableSessionId(task: TaskRecord, idleSessionIds: ReadonlySet<string> | undefined): string | undefined;
|
|
@@ -124,6 +124,14 @@ export interface TaskRecord {
|
|
|
124
124
|
* absent falls back to the host default (agent-default-model).
|
|
125
125
|
*/
|
|
126
126
|
model?: string;
|
|
127
|
+
/**
|
|
128
|
+
* Whether later executions continue in the previous execution's session
|
|
129
|
+
* (issue #1419) instead of minting a fresh conversation per run. Absent or
|
|
130
|
+
* false keeps the historical one-session-per-execution behavior; the reuse
|
|
131
|
+
* itself only happens when that session is idle and still present (see
|
|
132
|
+
* {@link reusableSessionId}).
|
|
133
|
+
*/
|
|
134
|
+
reuseSession?: boolean;
|
|
127
135
|
/**
|
|
128
136
|
* Frozen context snapshot for a continuation card; absent on plain tasks.
|
|
129
137
|
* Sanitized before it enters the ledger (redaction, slash-command taint,
|
|
@@ -171,6 +179,8 @@ export interface NewTaskInput {
|
|
|
171
179
|
permission?: TaskPermission;
|
|
172
180
|
/** Optional pinned model for the execution session; absent = host default. */
|
|
173
181
|
model?: string;
|
|
182
|
+
/** Reuse the previous execution's session for later runs (issue #1419). */
|
|
183
|
+
reuseSession?: boolean;
|
|
174
184
|
/**
|
|
175
185
|
* Optional scheduled-run rule requested at creation time (the new-task
|
|
176
186
|
* dialog): an enable flag plus a 5-field cron expression. The create use
|
|
@@ -15,7 +15,7 @@ import type { TaskHandoverInput } from '../handover.ts';
|
|
|
15
15
|
* Editable fields on a task (the update patch surface). `freeze` replaces the
|
|
16
16
|
* continuation-card snapshot (restamping frozenAt); an explicit null clears it.
|
|
17
17
|
*/
|
|
18
|
-
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model'>> & {
|
|
18
|
+
export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession'>> & {
|
|
19
19
|
freeze?: FreezeSnapshot & {
|
|
20
20
|
redacted?: boolean;
|
|
21
21
|
} | null;
|
|
@@ -53,6 +53,8 @@ export declare class HostTaskLedger {
|
|
|
53
53
|
constructor(dir?: string, now?: () => number, options?: {
|
|
54
54
|
sessionDefaultPermission?: TaskPermission;
|
|
55
55
|
});
|
|
56
|
+
/** Remove leftover *.tmp-* files from previous crashes or interrupted writes. */
|
|
57
|
+
private cleanStaleTemporaryFiles;
|
|
56
58
|
/** Revision + scheduler without any task cloning; feeds the SSE event frame. */
|
|
57
59
|
summary(): {
|
|
58
60
|
revision: number;
|
|
@@ -65,7 +65,25 @@ export declare class HostExecutionRunner {
|
|
|
65
65
|
});
|
|
66
66
|
private invoke;
|
|
67
67
|
private stream;
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Launch one execution. Without `options.reuseSessionId` a fresh session is
|
|
70
|
+
* created, renamed, pinned, and prompted (the historical contract). With it,
|
|
71
|
+
* the run continues in that existing session (issue #1419): the conversation
|
|
72
|
+
* keeps its title and history, the pinned permission/model are re-asserted so
|
|
73
|
+
* the task's execution contract still holds, and the prompt is queued.
|
|
74
|
+
* @param task - the task to run.
|
|
75
|
+
* @param options - optional session to continue in.
|
|
76
|
+
* @returns the session id the execution runs in.
|
|
77
|
+
*/
|
|
78
|
+
launch(task: TaskRecord, options?: {
|
|
79
|
+
reuseSessionId?: string;
|
|
80
|
+
}): Promise<string>;
|
|
81
|
+
/**
|
|
82
|
+
* Re-assert the pinned execution contract on a session and queue the task
|
|
83
|
+
* prompt. Shared by the fresh-session and reuse paths so both apply exactly
|
|
84
|
+
* the same permission/model pins before the prompt.
|
|
85
|
+
*/
|
|
86
|
+
private pinAndPrompt;
|
|
69
87
|
listRunning(): Promise<{
|
|
70
88
|
known: true;
|
|
71
89
|
count: number;
|
|
@@ -15,6 +15,13 @@ export declare class TaskBoardHostService {
|
|
|
15
15
|
private pollInFlight;
|
|
16
16
|
private tickInFlight;
|
|
17
17
|
private active;
|
|
18
|
+
/**
|
|
19
|
+
* Ids the last roster poll saw as present and idle; undefined while the
|
|
20
|
+
* roster is unknown. Session reuse (issue #1419) requires this positive
|
|
21
|
+
* evidence, so a launch before the first successful poll mints a fresh
|
|
22
|
+
* conversation instead of prompting into a session it cannot see.
|
|
23
|
+
*/
|
|
24
|
+
private idleSessionIds;
|
|
18
25
|
private preventIdleSleep;
|
|
19
26
|
private lastPowerJson;
|
|
20
27
|
private readonly now;
|
|
@@ -46,3 +53,14 @@ export declare class TaskBoardHostService {
|
|
|
46
53
|
private syncPowerReasons;
|
|
47
54
|
private emit;
|
|
48
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Install stream error listeners on process.stderr and process.stdout so that
|
|
58
|
+
* transient write failures (e.g. ENOSPC when the disk is full, or EPIPE on a
|
|
59
|
+
* closed pipe) never emit unhandled 'error' events that kill the Node.js host process.
|
|
60
|
+
*/
|
|
61
|
+
export declare function installStreamErrorGuards(): void;
|
|
62
|
+
/**
|
|
63
|
+
* Defensively log to console.error without letting stderr write failures
|
|
64
|
+
* (e.g. ENOSPC from SyncWriteStream on redirected logs) crash the host process.
|
|
65
|
+
*/
|
|
66
|
+
export declare function safeConsoleError(message: string, ...args: unknown[]): void;
|
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.19",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -6,27 +6,41 @@ import { useEffect, useState } from 'react'
|
|
|
6
6
|
import type { BoardController } from '../../core/controller.ts'
|
|
7
7
|
import { isValidCron, nextRunAtMs } from '../../core/schedule.ts'
|
|
8
8
|
import { parseFreezeRequest } from '../../core/freeze-snapshot.ts'
|
|
9
|
-
import { TASK_PERMISSIONS, type TaskPermission } from '../../core/tasks.ts'
|
|
9
|
+
import { TASK_PERMISSIONS, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
|
|
10
10
|
import { t, type TaskBoardKey } from '../locales.ts'
|
|
11
11
|
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
12
12
|
import { ModalShell, TaskContentFields } from './TaskForm.tsx'
|
|
13
13
|
import css from '../board.module.css'
|
|
14
14
|
|
|
15
|
+
export interface NewTaskModalProps {
|
|
16
|
+
controller: BoardController
|
|
17
|
+
onClose: () => void
|
|
18
|
+
/** Optional task template to clone/duplicate from. */
|
|
19
|
+
initialTask?: TaskRecord
|
|
20
|
+
/** Optional callback after successful duplication (e.g. to archive source). */
|
|
21
|
+
onDuplicateSuccess?: (sourceTaskId: string) => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
/** New-task form overlay. */
|
|
16
|
-
export function NewTaskModal({ controller, onClose
|
|
17
|
-
const
|
|
18
|
-
const [
|
|
19
|
-
const [
|
|
20
|
-
const [
|
|
21
|
-
const [
|
|
22
|
-
const [
|
|
23
|
-
const [
|
|
24
|
-
const [
|
|
25
|
-
const [
|
|
25
|
+
export function NewTaskModal({ controller, onClose, initialTask, onDuplicateSuccess }: NewTaskModalProps) {
|
|
26
|
+
const isDuplicate = initialTask !== undefined
|
|
27
|
+
const [title, setTitle] = useState(initialTask?.title ?? '')
|
|
28
|
+
const [description, setDescription] = useState(initialTask?.description ?? '')
|
|
29
|
+
const [prompt, setPrompt] = useState(initialTask?.prompt ?? '')
|
|
30
|
+
const [workspaceId, setWorkspaceId] = useState(initialTask?.workspaceId ?? '')
|
|
31
|
+
const [mode, setMode] = useState(initialTask?.mode ?? '')
|
|
32
|
+
const [permission, setPermission] = useState(initialTask?.permission ?? '')
|
|
33
|
+
const [model, setModel] = useState(initialTask?.model ?? '')
|
|
34
|
+
const [reuseSession, setReuseSession] = useState(initialTask?.reuseSession ?? false)
|
|
35
|
+
const [scheduleEnabled, setScheduleEnabled] = useState(initialTask?.schedule?.enabled ?? false)
|
|
36
|
+
const [scheduleCron, setScheduleCron] = useState(initialTask?.schedule?.cron ?? '')
|
|
26
37
|
const [scheduleError, setScheduleError] = useState<string | undefined>(undefined)
|
|
27
38
|
const [freezeText, setFreezeText] = useState('')
|
|
28
39
|
const [freezeError, setFreezeError] = useState<string | undefined>(undefined)
|
|
29
|
-
const [handoverText, setHandoverText] = useState(
|
|
40
|
+
const [handoverText, setHandoverText] = useState(
|
|
41
|
+
initialTask?.handover?.references !== undefined ? initialTask.handover.references.join('\n') : '',
|
|
42
|
+
)
|
|
43
|
+
const [archiveOriginal, setArchiveOriginal] = useState(true)
|
|
30
44
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
31
45
|
const [pending, setPending] = useState(false)
|
|
32
46
|
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
@@ -78,6 +92,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
78
92
|
mode: mode === '' ? undefined : mode,
|
|
79
93
|
permission: permission === '' ? undefined : permission as TaskPermission,
|
|
80
94
|
model: model === '' ? undefined : model,
|
|
95
|
+
...(reuseSession ? { reuseSession: true } : {}),
|
|
81
96
|
schedule: scheduleEnabled ? { enabled: true, cron: scheduleCron.trim() } : undefined,
|
|
82
97
|
})
|
|
83
98
|
if (task === undefined) {
|
|
@@ -85,6 +100,13 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
85
100
|
setError(controller.getSnapshot().transportError ?? t('new.required'))
|
|
86
101
|
return
|
|
87
102
|
}
|
|
103
|
+
if (isDuplicate && archiveOriginal && initialTask !== undefined) {
|
|
104
|
+
if (onDuplicateSuccess !== undefined) {
|
|
105
|
+
await onDuplicateSuccess(initialTask.id)
|
|
106
|
+
} else {
|
|
107
|
+
await controller.archiveTask(initialTask.id)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
88
110
|
onClose()
|
|
89
111
|
}
|
|
90
112
|
|
|
@@ -93,10 +115,12 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
93
115
|
? nextRunAtMs(scheduleCron, Date.now())
|
|
94
116
|
: undefined
|
|
95
117
|
|
|
118
|
+
const modalTitle = isDuplicate ? t('new.duplicateTitle') : t('board.new')
|
|
119
|
+
|
|
96
120
|
return (
|
|
97
121
|
<ModalShell
|
|
98
|
-
ariaLabel={
|
|
99
|
-
title={
|
|
122
|
+
ariaLabel={modalTitle}
|
|
123
|
+
title={modalTitle}
|
|
100
124
|
error={error}
|
|
101
125
|
pending={pending}
|
|
102
126
|
submitLabel={t('new.submit')}
|
|
@@ -197,6 +221,16 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
197
221
|
</select>
|
|
198
222
|
</label>
|
|
199
223
|
|
|
224
|
+
<label className={css.scheduleToggle}>
|
|
225
|
+
<input
|
|
226
|
+
type="checkbox"
|
|
227
|
+
checked={reuseSession}
|
|
228
|
+
onChange={event => { setReuseSession(event.target.checked) }}
|
|
229
|
+
/>
|
|
230
|
+
<span>{t('exec.reuseSession')}</span>
|
|
231
|
+
</label>
|
|
232
|
+
<p className={css.detailText}>{t('exec.reuseSessionHint')}</p>
|
|
233
|
+
|
|
200
234
|
<section className={css.detailSection}>
|
|
201
235
|
<h4>{t('detail.schedule')}</h4>
|
|
202
236
|
<label className={css.scheduleToggle}>
|
|
@@ -246,6 +280,16 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
246
280
|
</>
|
|
247
281
|
)}
|
|
248
282
|
</section>
|
|
283
|
+
{isDuplicate && (
|
|
284
|
+
<label className={css.checkboxLabel} style={{ marginTop: '12px' }}>
|
|
285
|
+
<input
|
|
286
|
+
type="checkbox"
|
|
287
|
+
checked={archiveOriginal}
|
|
288
|
+
onChange={event => { setArchiveOriginal(event.target.checked) }}
|
|
289
|
+
/>
|
|
290
|
+
<span>{t('new.archiveOriginal')}</span>
|
|
291
|
+
</label>
|
|
292
|
+
)}
|
|
249
293
|
</ModalShell>
|
|
250
294
|
)
|
|
251
295
|
}
|