@linxin666/dsh-client-ui-task-board 0.2.7 → 0.2.9
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/LICENSE +202 -29
- package/lib/index.js +154 -22
- package/lib/types/host-ledger.d.ts +37 -0
- package/lib/types/host-service.d.ts +0 -1
- package/package.json +17 -14
- package/src/host-ledger.ts +170 -12
- package/src/host-service.ts +32 -27
package/LICENSE
CHANGED
|
@@ -1,29 +1,202 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/lib/index.js
CHANGED
|
@@ -852,8 +852,48 @@ function cloneTasks(tasks) {
|
|
|
852
852
|
function hasOpenExecution(task) {
|
|
853
853
|
return task.executions.some((execution) => execution.endedAt === void 0);
|
|
854
854
|
}
|
|
855
|
+
/**
|
|
856
|
+
* Process states that are dead but still occupy the PID table: `Z` (zombie)
|
|
857
|
+
* and `X` (dead, being reaped). `process.kill(pid, 0)` reports such PIDs as
|
|
858
|
+
* alive, so a crash leftover whose child was never reaped would otherwise be
|
|
859
|
+
* mistaken for a live owner and block ledger startup forever.
|
|
860
|
+
*/
|
|
861
|
+
const DEAD_STATES = /* @__PURE__ */ new Set(["Z", "X"]);
|
|
862
|
+
/**
|
|
863
|
+
* Best-effort single-letter process state ('R','S','D','Z',...) or undefined
|
|
864
|
+
* when no probe is available on this platform. Linux reads /proc/<pid>/stat
|
|
865
|
+
* directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows
|
|
866
|
+
* has no zombie state, so it returns undefined and the kill(0) probe alone
|
|
867
|
+
* is authoritative there.
|
|
868
|
+
*/
|
|
869
|
+
function processState(pid) {
|
|
870
|
+
if (process.platform === "linux") try {
|
|
871
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
872
|
+
const end = stat.lastIndexOf(")");
|
|
873
|
+
if (end === -1) return void 0;
|
|
874
|
+
return stat.slice(end + 2).split(" ")[0] || void 0;
|
|
875
|
+
} catch {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (process.platform === "win32") return void 0;
|
|
879
|
+
try {
|
|
880
|
+
const probe = spawnSync("ps", [
|
|
881
|
+
"-o",
|
|
882
|
+
"stat=",
|
|
883
|
+
"-p",
|
|
884
|
+
String(pid)
|
|
885
|
+
], { timeout: PROCESS_PROBE_TIMEOUT_MS });
|
|
886
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return void 0;
|
|
887
|
+
const state = probe.stdout.toString("utf8").trim();
|
|
888
|
+
return state.length > 0 ? state[0] : void 0;
|
|
889
|
+
} catch {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
855
893
|
function processIsAlive(pid) {
|
|
856
894
|
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
895
|
+
const state = processState(pid);
|
|
896
|
+
if (state !== void 0 && DEAD_STATES.has(state)) return false;
|
|
857
897
|
try {
|
|
858
898
|
process.kill(pid, 0);
|
|
859
899
|
return true;
|
|
@@ -865,6 +905,28 @@ const PROCESS_PROBE_TIMEOUT_MS = 3e3;
|
|
|
865
905
|
let ownStartTime;
|
|
866
906
|
let ownStartTimeResolved = false;
|
|
867
907
|
/**
|
|
908
|
+
* Exact process start time (Unix epoch ms) on Linux, read straight from
|
|
909
|
+
* /proc (field 22 = start ticks since boot, btime = boot epoch seconds).
|
|
910
|
+
* No subprocess and no rounding, so the recorded `startedAt` from a previous
|
|
911
|
+
* boot compares exactly against the live process identity.
|
|
912
|
+
*/
|
|
913
|
+
function linuxStartTimeMs(pid) {
|
|
914
|
+
try {
|
|
915
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
916
|
+
const end = stat.lastIndexOf(")");
|
|
917
|
+
if (end === -1) return void 0;
|
|
918
|
+
const ticks = Number(stat.slice(end + 2).split(" ")[19]);
|
|
919
|
+
if (!Number.isFinite(ticks)) return void 0;
|
|
920
|
+
const bootMatch = /^btime\s+(\d+)/m.exec(readFileSync("/proc/stat", "utf8"));
|
|
921
|
+
if (bootMatch === null) return void 0;
|
|
922
|
+
const btime = Number(bootMatch[1]);
|
|
923
|
+
if (!Number.isFinite(btime)) return void 0;
|
|
924
|
+
return btime * 1e3 + ticks * 1e3 / 100;
|
|
925
|
+
} catch {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
868
930
|
* Best-effort start time (Unix epoch ms) of a live process. Used to prove
|
|
869
931
|
* whether the ledger lock really belongs to the PID recorded in it, so a
|
|
870
932
|
* crash leftover whose PID was reused by an unrelated process (issue #786)
|
|
@@ -872,6 +934,7 @@ let ownStartTimeResolved = false;
|
|
|
872
934
|
* undefined when the platform probe is unavailable; callers fail closed.
|
|
873
935
|
*/
|
|
874
936
|
function processStartTimeMs(pid) {
|
|
937
|
+
if (process.platform === "linux") return linuxStartTimeMs(pid);
|
|
875
938
|
if (process.platform === "win32") {
|
|
876
939
|
const probe = spawnSync("powershell", [
|
|
877
940
|
"-NoProfile",
|
|
@@ -924,6 +987,21 @@ function ownProcessStartTimeMs() {
|
|
|
924
987
|
}
|
|
925
988
|
return ownStartTime;
|
|
926
989
|
}
|
|
990
|
+
/**
|
|
991
|
+
* Bounded tolerance for legacy lock records. Locks written before the
|
|
992
|
+
* ms-precise probe recorded `startedAt` from `ps -o lstart=` at whole-second
|
|
993
|
+
* resolution; probing the SAME live process exactly (via /proc) then differs
|
|
994
|
+
* in the sub-second remainder. Treating that as PID reuse would steal a live
|
|
995
|
+
* owner's lock during a rolling upgrade and start a second ledger writer.
|
|
996
|
+
* Records written by the ms-precise probe carry `probe: 'exact'` and are
|
|
997
|
+
* compared strictly; anything else (older locks, second-granularity probes)
|
|
998
|
+
* falls back to this bounded tolerance.
|
|
999
|
+
*/
|
|
1000
|
+
const LEGACY_START_TOLERANCE_MS = 2e3;
|
|
1001
|
+
/** Whether the recorded start time proves the recorded PID is another process. */
|
|
1002
|
+
function startTimeMismatch(recorded, actual, exact) {
|
|
1003
|
+
return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS;
|
|
1004
|
+
}
|
|
927
1005
|
function betterExecution(a, b) {
|
|
928
1006
|
if (a.endedAt === void 0 && b.endedAt !== void 0) return b;
|
|
929
1007
|
if (b.endedAt === void 0 && a.endedAt !== void 0) return a;
|
|
@@ -1009,6 +1087,52 @@ var HostTaskLedger = class {
|
|
|
1009
1087
|
scheduler
|
|
1010
1088
|
};
|
|
1011
1089
|
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Runtime-only projection for the 5 s Host poll. It copies just primitive
|
|
1092
|
+
* identifiers and timestamps, never the complete task/execution history or
|
|
1093
|
+
* an authoritative mutable object from the ledger.
|
|
1094
|
+
*/
|
|
1095
|
+
runtimeView() {
|
|
1096
|
+
let armedSchedules = 0;
|
|
1097
|
+
const openExecutions = [];
|
|
1098
|
+
for (const task of this.document.tasks) {
|
|
1099
|
+
if (task.archivedAt === void 0 && task.schedule?.enabled === true) armedSchedules += 1;
|
|
1100
|
+
for (const execution of task.executions) {
|
|
1101
|
+
if (execution.endedAt !== void 0) continue;
|
|
1102
|
+
openExecutions.push({
|
|
1103
|
+
taskId: task.id,
|
|
1104
|
+
executionId: execution.id,
|
|
1105
|
+
sessionId: execution.sessionId,
|
|
1106
|
+
startedAt: execution.startedAt
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return {
|
|
1111
|
+
armedSchedules,
|
|
1112
|
+
openExecutions
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
/** Count armed, non-archived schedules without cloning task histories. */
|
|
1116
|
+
armedScheduleCount() {
|
|
1117
|
+
let count = 0;
|
|
1118
|
+
for (const task of this.document.tasks) if (task.archivedAt === void 0 && task.schedule?.enabled === true) count += 1;
|
|
1119
|
+
return count;
|
|
1120
|
+
}
|
|
1121
|
+
/** Return value-only references for schedules due at the supplied Host time. */
|
|
1122
|
+
dueSchedules(now) {
|
|
1123
|
+
const due = [];
|
|
1124
|
+
for (const task of this.document.tasks) {
|
|
1125
|
+
if (task.archivedAt !== void 0) continue;
|
|
1126
|
+
const schedule = task.schedule;
|
|
1127
|
+
if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) continue;
|
|
1128
|
+
due.push({
|
|
1129
|
+
taskId: task.id,
|
|
1130
|
+
cron: schedule.cron,
|
|
1131
|
+
nextRunAt: schedule.nextRunAt
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
return due;
|
|
1135
|
+
}
|
|
1012
1136
|
subscribe(listener) {
|
|
1013
1137
|
this.listeners.add(listener);
|
|
1014
1138
|
return () => {
|
|
@@ -1369,10 +1493,13 @@ var HostTaskLedger = class {
|
|
|
1369
1493
|
acquireLock() {
|
|
1370
1494
|
for (let attempt = 0; attempt < 2; attempt += 1) try {
|
|
1371
1495
|
const fd = openSync(this.lockFile, "wx", 384);
|
|
1496
|
+
const startedAt = ownProcessStartTimeMs();
|
|
1497
|
+
const probe = process.platform === "linux" || process.platform === "win32" ? "exact" : "legacy";
|
|
1372
1498
|
writeFileSync(fd, JSON.stringify({
|
|
1373
1499
|
pid: process.pid,
|
|
1374
1500
|
token: this.lockToken,
|
|
1375
|
-
startedAt
|
|
1501
|
+
startedAt,
|
|
1502
|
+
probe
|
|
1376
1503
|
}), { encoding: "utf8" });
|
|
1377
1504
|
fsyncSync(fd);
|
|
1378
1505
|
try {
|
|
@@ -1383,23 +1510,25 @@ var HostTaskLedger = class {
|
|
|
1383
1510
|
if (error.code !== "EEXIST") throw error;
|
|
1384
1511
|
let pid;
|
|
1385
1512
|
let ownerStartedAt;
|
|
1513
|
+
let ownerExact = false;
|
|
1386
1514
|
try {
|
|
1387
1515
|
const owner = JSON.parse(readFileSync(this.lockFile, "utf8"));
|
|
1388
1516
|
if (typeof owner.pid === "number") pid = owner.pid;
|
|
1389
1517
|
if (typeof owner.startedAt === "number") ownerStartedAt = owner.startedAt;
|
|
1518
|
+
ownerExact = owner.probe === "exact";
|
|
1390
1519
|
} catch {
|
|
1391
|
-
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}`);
|
|
1520
|
+
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`);
|
|
1392
1521
|
}
|
|
1393
1522
|
if (pid !== void 0 && processIsAlive(pid)) {
|
|
1394
1523
|
const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid);
|
|
1395
|
-
if (!(actualStartedAt !== void 0 && (ownerStartedAt !== void 0 ? actualStartedAt
|
|
1524
|
+
if (!(actualStartedAt !== void 0 && (ownerStartedAt !== void 0 ? startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact) : (() => {
|
|
1396
1525
|
try {
|
|
1397
1526
|
return statSync(this.lockFile).mtimeMs < actualStartedAt;
|
|
1398
1527
|
} catch {
|
|
1399
1528
|
return true;
|
|
1400
1529
|
}
|
|
1401
1530
|
})()))) {
|
|
1402
|
-
const hint = ownerStartedAt !== void 0 && actualStartedAt
|
|
1531
|
+
const hint = ownerStartedAt !== void 0 && actualStartedAt !== void 0 && !startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact) ? "" : `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`;
|
|
1403
1532
|
throw new Error(`task-board ledger is already owned by process ${pid}${hint}`);
|
|
1404
1533
|
}
|
|
1405
1534
|
}
|
|
@@ -1979,24 +2108,33 @@ var TaskBoardHostService = class {
|
|
|
1979
2108
|
}
|
|
1980
2109
|
async pollSessions() {
|
|
1981
2110
|
if (this.disposed) return;
|
|
1982
|
-
if (!this.active &&
|
|
2111
|
+
if (!this.active && this.ledger.runtimeView().openExecutions.length === 0) return;
|
|
1983
2112
|
const running = await this.runner.listRunning();
|
|
1984
2113
|
const previous = this.power.snapshot();
|
|
2114
|
+
if (!running.known) {
|
|
2115
|
+
this.power.updateReasons({
|
|
2116
|
+
runningSessions: previous.runningSessions,
|
|
2117
|
+
armedSchedules: this.ledger.armedScheduleCount(),
|
|
2118
|
+
sessionStateKnown: false
|
|
2119
|
+
});
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
const runtime = this.ledger.runtimeView();
|
|
1985
2123
|
this.power.updateReasons({
|
|
1986
|
-
runningSessions: running.
|
|
1987
|
-
armedSchedules:
|
|
1988
|
-
sessionStateKnown:
|
|
2124
|
+
runningSessions: running.count,
|
|
2125
|
+
armedSchedules: runtime.armedSchedules,
|
|
2126
|
+
sessionStateKnown: true
|
|
1989
2127
|
});
|
|
1990
|
-
|
|
2128
|
+
await this.reconcileExecutions(running.items, runtime.openExecutions);
|
|
1991
2129
|
}
|
|
1992
2130
|
/** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
|
|
1993
|
-
async reconcileExecutions(sessions) {
|
|
1994
|
-
for (const
|
|
1995
|
-
if (execution.sessionId === void 0
|
|
2131
|
+
async reconcileExecutions(sessions, executions) {
|
|
2132
|
+
for (const execution of executions) {
|
|
2133
|
+
if (execution.sessionId === void 0) continue;
|
|
1996
2134
|
try {
|
|
1997
2135
|
const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions);
|
|
1998
2136
|
if (result.outcome === "pending") continue;
|
|
1999
|
-
this.ledger.settle(
|
|
2137
|
+
this.ledger.settle(execution.taskId, execution.executionId, result.outcome, "error" in result ? result.error : void 0);
|
|
2000
2138
|
} catch {}
|
|
2001
2139
|
}
|
|
2002
2140
|
}
|
|
@@ -2010,20 +2148,14 @@ var TaskBoardHostService = class {
|
|
|
2010
2148
|
this.ledger.skipMissed(now);
|
|
2011
2149
|
return;
|
|
2012
2150
|
}
|
|
2013
|
-
for (const
|
|
2014
|
-
if (task.archivedAt !== void 0) continue;
|
|
2015
|
-
const schedule = task.schedule;
|
|
2016
|
-
if (schedule === void 0 || !schedule.enabled || schedule.nextRunAt === void 0 || schedule.nextRunAt > now) continue;
|
|
2151
|
+
for (const schedule of this.ledger.dueSchedules(now)) {
|
|
2017
2152
|
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt);
|
|
2018
|
-
const opened = this.ledger.openScheduled(
|
|
2153
|
+
const opened = this.ledger.openScheduled(schedule.taskId, next, now);
|
|
2019
2154
|
if (opened !== void 0) this.scheduleLaunch(opened);
|
|
2020
2155
|
}
|
|
2021
2156
|
}
|
|
2022
2157
|
armedSchedules() {
|
|
2023
|
-
return this.ledger.
|
|
2024
|
-
}
|
|
2025
|
-
hasOpenExecutions() {
|
|
2026
|
-
return this.ledger.state().tasks.some((task) => task.executions.some((execution) => execution.endedAt === void 0));
|
|
2158
|
+
return this.ledger.armedScheduleCount();
|
|
2027
2159
|
}
|
|
2028
2160
|
scheduleLaunch(opened) {
|
|
2029
2161
|
this.launch(opened).catch((error) => {
|
|
@@ -9,6 +9,33 @@ export interface OpenedRun {
|
|
|
9
9
|
task: TaskRecord;
|
|
10
10
|
execution: ExecutionRecord;
|
|
11
11
|
}
|
|
12
|
+
/** Minimal value copy used by the Host session monitor. */
|
|
13
|
+
export interface OpenExecutionReference {
|
|
14
|
+
readonly taskId: string;
|
|
15
|
+
readonly executionId: string;
|
|
16
|
+
readonly sessionId: string | undefined;
|
|
17
|
+
readonly startedAt: number;
|
|
18
|
+
}
|
|
19
|
+
/** Minimal value copy used by the Host scheduler. */
|
|
20
|
+
export interface DueScheduleReference {
|
|
21
|
+
readonly taskId: string;
|
|
22
|
+
readonly cron: string;
|
|
23
|
+
readonly nextRunAt: number;
|
|
24
|
+
}
|
|
25
|
+
/** Derived runtime data for one session-poll pass. */
|
|
26
|
+
export interface LedgerRuntimeView {
|
|
27
|
+
readonly armedSchedules: number;
|
|
28
|
+
readonly openExecutions: readonly OpenExecutionReference[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort single-letter process state ('R','S','D','Z',...) or undefined
|
|
32
|
+
* when no probe is available on this platform. Linux reads /proc/<pid>/stat
|
|
33
|
+
* directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows
|
|
34
|
+
* has no zombie state, so it returns undefined and the kill(0) probe alone
|
|
35
|
+
* is authoritative there.
|
|
36
|
+
*/
|
|
37
|
+
export declare function processState(pid: number): string | undefined;
|
|
38
|
+
export declare function processIsAlive(pid: number): boolean;
|
|
12
39
|
export declare class HostTaskLedger {
|
|
13
40
|
private readonly now;
|
|
14
41
|
private document;
|
|
@@ -27,6 +54,16 @@ export declare class HostTaskLedger {
|
|
|
27
54
|
scheduler: TaskBoardSchedulerSnapshot;
|
|
28
55
|
};
|
|
29
56
|
state(): LedgerState;
|
|
57
|
+
/**
|
|
58
|
+
* Runtime-only projection for the 5 s Host poll. It copies just primitive
|
|
59
|
+
* identifiers and timestamps, never the complete task/execution history or
|
|
60
|
+
* an authoritative mutable object from the ledger.
|
|
61
|
+
*/
|
|
62
|
+
runtimeView(): LedgerRuntimeView;
|
|
63
|
+
/** Count armed, non-archived schedules without cloning task histories. */
|
|
64
|
+
armedScheduleCount(): number;
|
|
65
|
+
/** Return value-only references for schedules due at the supplied Host time. */
|
|
66
|
+
dueSchedules(now: number): DueScheduleReference[];
|
|
30
67
|
subscribe(listener: () => void): () => void;
|
|
31
68
|
dispose(): void;
|
|
32
69
|
applyRequest(requestId: string, action: TaskBoardAction): {
|
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.2.
|
|
4
|
+
"version": "0.2.9",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"./package.json": "./package.json"
|
|
23
23
|
},
|
|
24
24
|
"dsh": {
|
|
25
|
+
"engines": {
|
|
26
|
+
"dsh": ">=0.1.1-rc.1"
|
|
27
|
+
},
|
|
25
28
|
"bundle": {
|
|
26
29
|
"patch": "./cordis.patch.yml"
|
|
27
30
|
},
|
|
@@ -35,8 +38,8 @@
|
|
|
35
38
|
}
|
|
36
39
|
},
|
|
37
40
|
"peerDependencies": {
|
|
38
|
-
"@deepseek-ai/dsh-agent": "^0.1.1-rc.
|
|
39
|
-
"@deepseek-ai/dsh-commands": "^0.1.1-rc.
|
|
41
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
|
|
42
|
+
"@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
|
|
40
43
|
"react": "^18.2.0"
|
|
41
44
|
},
|
|
42
45
|
"dependencies": {
|
|
@@ -44,17 +47,17 @@
|
|
|
44
47
|
},
|
|
45
48
|
"devDependencies": {
|
|
46
49
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
47
|
-
"@deepseek-ai/dsh-agent": "^0.1.1-rc.
|
|
48
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.
|
|
49
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.
|
|
50
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.
|
|
51
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.
|
|
52
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.
|
|
53
|
-
"@deepseek-ai/dsh-commands": "^0.1.1-rc.
|
|
54
|
-
"@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.
|
|
55
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.
|
|
56
|
-
"@deepseek-ai/dsh-settings": "^0.1.1-rc.
|
|
57
|
-
"@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.
|
|
50
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
|
|
51
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
|
|
60
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
|
|
58
61
|
"@types/react": "~18.3.1",
|
|
59
62
|
"@types/react-dom": "^18.3.5",
|
|
60
63
|
"@types/node": "^22.20.0",
|
package/src/host-ledger.ts
CHANGED
|
@@ -41,6 +41,27 @@ export interface OpenedRun {
|
|
|
41
41
|
execution: ExecutionRecord
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Minimal value copy used by the Host session monitor. */
|
|
45
|
+
export interface OpenExecutionReference {
|
|
46
|
+
readonly taskId: string
|
|
47
|
+
readonly executionId: string
|
|
48
|
+
readonly sessionId: string | undefined
|
|
49
|
+
readonly startedAt: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Minimal value copy used by the Host scheduler. */
|
|
53
|
+
export interface DueScheduleReference {
|
|
54
|
+
readonly taskId: string
|
|
55
|
+
readonly cron: string
|
|
56
|
+
readonly nextRunAt: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Derived runtime data for one session-poll pass. */
|
|
60
|
+
export interface LedgerRuntimeView {
|
|
61
|
+
readonly armedSchedules: number
|
|
62
|
+
readonly openExecutions: readonly OpenExecutionReference[]
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
const MAX_REQUEST_CACHE = 256
|
|
45
66
|
|
|
46
67
|
interface CachedRequest {
|
|
@@ -59,8 +80,47 @@ function hasOpenExecution(task: TaskRecord): boolean {
|
|
|
59
80
|
return task.executions.some(execution => execution.endedAt === undefined)
|
|
60
81
|
}
|
|
61
82
|
|
|
62
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Process states that are dead but still occupy the PID table: `Z` (zombie)
|
|
85
|
+
* and `X` (dead, being reaped). `process.kill(pid, 0)` reports such PIDs as
|
|
86
|
+
* alive, so a crash leftover whose child was never reaped would otherwise be
|
|
87
|
+
* mistaken for a live owner and block ledger startup forever.
|
|
88
|
+
*/
|
|
89
|
+
const DEAD_STATES = new Set(['Z', 'X'])
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Best-effort single-letter process state ('R','S','D','Z',...) or undefined
|
|
93
|
+
* when no probe is available on this platform. Linux reads /proc/<pid>/stat
|
|
94
|
+
* directly (no subprocess); other POSIX shells out to `ps -o stat=`; Windows
|
|
95
|
+
* has no zombie state, so it returns undefined and the kill(0) probe alone
|
|
96
|
+
* is authoritative there.
|
|
97
|
+
*/
|
|
98
|
+
export function processState(pid: number): string | undefined {
|
|
99
|
+
if (process.platform === 'linux') {
|
|
100
|
+
try {
|
|
101
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
102
|
+
const end = stat.lastIndexOf(')')
|
|
103
|
+
if (end === -1) return undefined
|
|
104
|
+
return stat.slice(end + 2).split(' ')[0] || undefined
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined // no such process (or unreadable)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (process.platform === 'win32') return undefined
|
|
110
|
+
try {
|
|
111
|
+
const probe = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS })
|
|
112
|
+
if (probe.status !== 0 || probe.stdout.length === 0) return undefined
|
|
113
|
+
const state = probe.stdout.toString('utf8').trim()
|
|
114
|
+
return state.length > 0 ? state[0] : undefined
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function processIsAlive(pid: number): boolean {
|
|
63
121
|
if (!Number.isSafeInteger(pid) || pid <= 0) return false
|
|
122
|
+
const state = processState(pid)
|
|
123
|
+
if (state !== undefined && DEAD_STATES.has(state)) return false
|
|
64
124
|
try {
|
|
65
125
|
process.kill(pid, 0)
|
|
66
126
|
return true
|
|
@@ -74,6 +134,29 @@ const PROCESS_PROBE_TIMEOUT_MS = 3000
|
|
|
74
134
|
let ownStartTime: number | undefined
|
|
75
135
|
let ownStartTimeResolved = false
|
|
76
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Exact process start time (Unix epoch ms) on Linux, read straight from
|
|
139
|
+
* /proc (field 22 = start ticks since boot, btime = boot epoch seconds).
|
|
140
|
+
* No subprocess and no rounding, so the recorded `startedAt` from a previous
|
|
141
|
+
* boot compares exactly against the live process identity.
|
|
142
|
+
*/
|
|
143
|
+
function linuxStartTimeMs(pid: number): number | undefined {
|
|
144
|
+
try {
|
|
145
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
146
|
+
const end = stat.lastIndexOf(')')
|
|
147
|
+
if (end === -1) return undefined
|
|
148
|
+
const ticks = Number(stat.slice(end + 2).split(' ')[19])
|
|
149
|
+
if (!Number.isFinite(ticks)) return undefined
|
|
150
|
+
const bootMatch = /^btime\s+(\d+)/m.exec(readFileSync('/proc/stat', 'utf8'))
|
|
151
|
+
if (bootMatch === null) return undefined
|
|
152
|
+
const btime = Number(bootMatch[1])
|
|
153
|
+
if (!Number.isFinite(btime)) return undefined
|
|
154
|
+
return btime * 1000 + (ticks * 1000) / 100 // USER_HZ is 100 on Linux
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
77
160
|
/**
|
|
78
161
|
* Best-effort start time (Unix epoch ms) of a live process. Used to prove
|
|
79
162
|
* whether the ledger lock really belongs to the PID recorded in it, so a
|
|
@@ -82,6 +165,7 @@ let ownStartTimeResolved = false
|
|
|
82
165
|
* undefined when the platform probe is unavailable; callers fail closed.
|
|
83
166
|
*/
|
|
84
167
|
function processStartTimeMs(pid: number): number | undefined {
|
|
168
|
+
if (process.platform === 'linux') return linuxStartTimeMs(pid)
|
|
85
169
|
if (process.platform === 'win32') {
|
|
86
170
|
const probe = spawnSync(
|
|
87
171
|
'powershell',
|
|
@@ -93,8 +177,8 @@ function processStartTimeMs(pid: number): number | undefined {
|
|
|
93
177
|
const started = Number(probe.stdout.toString('utf8').trim())
|
|
94
178
|
return Number.isFinite(started) ? started : undefined
|
|
95
179
|
}
|
|
96
|
-
// POSIX: ps lstart with a forced English locale, falling
|
|
97
|
-
// elapsed-seconds column when lstart cannot be parsed.
|
|
180
|
+
// Other POSIX (macOS...): ps lstart with a forced English locale, falling
|
|
181
|
+
// back to the elapsed-seconds column when lstart cannot be parsed.
|
|
98
182
|
const env = { ...process.env, LC_ALL: 'C' }
|
|
99
183
|
const probe = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { timeout: PROCESS_PROBE_TIMEOUT_MS, env })
|
|
100
184
|
if (probe.status === 0 && probe.stdout.length > 0) {
|
|
@@ -116,6 +200,23 @@ function ownProcessStartTimeMs(): number | undefined {
|
|
|
116
200
|
return ownStartTime
|
|
117
201
|
}
|
|
118
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Bounded tolerance for legacy lock records. Locks written before the
|
|
205
|
+
* ms-precise probe recorded `startedAt` from `ps -o lstart=` at whole-second
|
|
206
|
+
* resolution; probing the SAME live process exactly (via /proc) then differs
|
|
207
|
+
* in the sub-second remainder. Treating that as PID reuse would steal a live
|
|
208
|
+
* owner's lock during a rolling upgrade and start a second ledger writer.
|
|
209
|
+
* Records written by the ms-precise probe carry `probe: 'exact'` and are
|
|
210
|
+
* compared strictly; anything else (older locks, second-granularity probes)
|
|
211
|
+
* falls back to this bounded tolerance.
|
|
212
|
+
*/
|
|
213
|
+
const LEGACY_START_TOLERANCE_MS = 2000
|
|
214
|
+
|
|
215
|
+
/** Whether the recorded start time proves the recorded PID is another process. */
|
|
216
|
+
function startTimeMismatch(recorded: number, actual: number, exact: boolean): boolean {
|
|
217
|
+
return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS
|
|
218
|
+
}
|
|
219
|
+
|
|
119
220
|
function betterExecution(a: ExecutionRecord, b: ExecutionRecord): ExecutionRecord {
|
|
120
221
|
if (a.endedAt === undefined && b.endedAt !== undefined) return b
|
|
121
222
|
if (b.endedAt === undefined && a.endedAt !== undefined) return a
|
|
@@ -204,6 +305,50 @@ export class HostTaskLedger {
|
|
|
204
305
|
return { revision, tasks: cloneTasks(this.document.tasks), scheduler }
|
|
205
306
|
}
|
|
206
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Runtime-only projection for the 5 s Host poll. It copies just primitive
|
|
310
|
+
* identifiers and timestamps, never the complete task/execution history or
|
|
311
|
+
* an authoritative mutable object from the ledger.
|
|
312
|
+
*/
|
|
313
|
+
runtimeView(): LedgerRuntimeView {
|
|
314
|
+
let armedSchedules = 0
|
|
315
|
+
const openExecutions: OpenExecutionReference[] = []
|
|
316
|
+
for (const task of this.document.tasks) {
|
|
317
|
+
if (task.archivedAt === undefined && task.schedule?.enabled === true) armedSchedules += 1
|
|
318
|
+
for (const execution of task.executions) {
|
|
319
|
+
if (execution.endedAt !== undefined) continue
|
|
320
|
+
openExecutions.push({
|
|
321
|
+
taskId: task.id,
|
|
322
|
+
executionId: execution.id,
|
|
323
|
+
sessionId: execution.sessionId,
|
|
324
|
+
startedAt: execution.startedAt,
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { armedSchedules, openExecutions }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Count armed, non-archived schedules without cloning task histories. */
|
|
332
|
+
armedScheduleCount(): number {
|
|
333
|
+
let count = 0
|
|
334
|
+
for (const task of this.document.tasks) {
|
|
335
|
+
if (task.archivedAt === undefined && task.schedule?.enabled === true) count += 1
|
|
336
|
+
}
|
|
337
|
+
return count
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Return value-only references for schedules due at the supplied Host time. */
|
|
341
|
+
dueSchedules(now: number): DueScheduleReference[] {
|
|
342
|
+
const due: DueScheduleReference[] = []
|
|
343
|
+
for (const task of this.document.tasks) {
|
|
344
|
+
if (task.archivedAt !== undefined) continue
|
|
345
|
+
const schedule = task.schedule
|
|
346
|
+
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
|
|
347
|
+
due.push({ taskId: task.id, cron: schedule.cron, nextRunAt: schedule.nextRunAt })
|
|
348
|
+
}
|
|
349
|
+
return due
|
|
350
|
+
}
|
|
351
|
+
|
|
207
352
|
subscribe(listener: () => void): () => void {
|
|
208
353
|
this.listeners.add(listener)
|
|
209
354
|
return () => { this.listeners.delete(listener) }
|
|
@@ -560,7 +705,13 @@ export class HostTaskLedger {
|
|
|
560
705
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
561
706
|
try {
|
|
562
707
|
const fd = openSync(this.lockFile, 'wx', 0o600)
|
|
563
|
-
|
|
708
|
+
const startedAt = ownProcessStartTimeMs()
|
|
709
|
+
// Linux /proc and Windows PowerShell probes are ms-precise; locks they
|
|
710
|
+
// write are compared strictly. Other POSIX probes (ps) stay
|
|
711
|
+
// second-granularity, so their records are compared with the bounded
|
|
712
|
+
// legacy tolerance.
|
|
713
|
+
const probe = process.platform === 'linux' || process.platform === 'win32' ? 'exact' : 'legacy'
|
|
714
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, token: this.lockToken, startedAt, probe }), { encoding: 'utf8' })
|
|
564
715
|
fsyncSync(fd)
|
|
565
716
|
try { chmodSync(this.lockFile, 0o600) } catch { /* Windows ACLs own access */ }
|
|
566
717
|
return fd
|
|
@@ -569,29 +720,36 @@ export class HostTaskLedger {
|
|
|
569
720
|
if (code !== 'EEXIST') throw error
|
|
570
721
|
let pid: number | undefined
|
|
571
722
|
let ownerStartedAt: number | undefined
|
|
723
|
+
let ownerExact = false
|
|
572
724
|
try {
|
|
573
|
-
const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown; startedAt?: unknown }
|
|
725
|
+
const owner = JSON.parse(readFileSync(this.lockFile, 'utf8')) as { pid?: unknown; startedAt?: unknown; probe?: unknown }
|
|
574
726
|
if (typeof owner.pid === 'number') pid = owner.pid
|
|
575
727
|
if (typeof owner.startedAt === 'number') ownerStartedAt = owner.startedAt
|
|
728
|
+
ownerExact = owner.probe === 'exact'
|
|
576
729
|
} catch {
|
|
577
|
-
|
|
730
|
+
// A power-loss mid-write can leave a truncated lock; the same event
|
|
731
|
+
// killed the writer, so fail closed but explain the recovery.
|
|
732
|
+
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`)
|
|
578
733
|
}
|
|
579
734
|
if (pid !== undefined && processIsAlive(pid)) {
|
|
580
735
|
const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid)
|
|
581
736
|
// A reused PID is exposed when the live process identity no longer
|
|
582
|
-
// matches the recorded one: either the recorded start time differs
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
737
|
+
// matches the recorded one: either the recorded start time differs
|
|
738
|
+
// beyond the probe's resolution (strict for ms-precise 'exact'
|
|
739
|
+
// records, a bounded legacy tolerance for old second-granularity
|
|
740
|
+
// records written by ps), or (legacy locks without a start time)
|
|
741
|
+
// the lock file predates the live process and therefore cannot
|
|
742
|
+
// have been written by it. Takeover is safe in both cases — the
|
|
743
|
+
// original owner is gone.
|
|
586
744
|
const staleReuse = actualStartedAt !== undefined && (
|
|
587
745
|
ownerStartedAt !== undefined
|
|
588
|
-
? actualStartedAt
|
|
746
|
+
? startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact)
|
|
589
747
|
: (() => {
|
|
590
748
|
try { return statSync(this.lockFile).mtimeMs < actualStartedAt } catch { return true }
|
|
591
749
|
})()
|
|
592
750
|
)
|
|
593
751
|
if (!staleReuse) {
|
|
594
|
-
const confirmedOwner = ownerStartedAt !== undefined && actualStartedAt
|
|
752
|
+
const confirmedOwner = ownerStartedAt !== undefined && actualStartedAt !== undefined && !startTimeMismatch(ownerStartedAt, actualStartedAt, ownerExact)
|
|
595
753
|
const hint = confirmedOwner
|
|
596
754
|
? ''
|
|
597
755
|
: `; if this PID was reused after a crash and no other DSH host is running, remove ${this.lockFile} manually and retry`
|
package/src/host-service.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
|
2
2
|
import { nextRunAtMs } from './core/schedule.ts'
|
|
3
3
|
import type { TaskRecord } from './core/tasks.ts'
|
|
4
|
-
import { HostTaskLedger, type OpenedRun } from './host-ledger.ts'
|
|
4
|
+
import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts'
|
|
5
5
|
import { HostExecutionRunner, SessionLaunchError, type SessionCommandDispatcher, type SessionSummary } from './host-runner.ts'
|
|
6
6
|
import { PowerInhibitor } from './power-inhibitor.ts'
|
|
7
7
|
import type { TaskBoardAction, TaskBoardEventPayload, TaskBoardSnapshot } from './protocol.ts'
|
|
@@ -136,31 +136,43 @@ export class TaskBoardHostService {
|
|
|
136
136
|
|
|
137
137
|
private async pollSessions(): Promise<void> {
|
|
138
138
|
if (this.disposed) return
|
|
139
|
-
if (!this.active &&
|
|
139
|
+
if (!this.active && this.ledger.runtimeView().openExecutions.length === 0) return
|
|
140
140
|
const running = await this.runner.listRunning()
|
|
141
141
|
const previous = this.power.snapshot()
|
|
142
|
+
if (!running.known) {
|
|
143
|
+
this.power.updateReasons({
|
|
144
|
+
runningSessions: previous.runningSessions,
|
|
145
|
+
armedSchedules: this.ledger.armedScheduleCount(),
|
|
146
|
+
sessionStateKnown: false,
|
|
147
|
+
})
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
// Read after the RPC so executions attached while it was in flight are
|
|
151
|
+
// included in this pass, matching the former full-state snapshot timing.
|
|
152
|
+
const runtime = this.ledger.runtimeView()
|
|
142
153
|
this.power.updateReasons({
|
|
143
|
-
runningSessions: running.
|
|
144
|
-
armedSchedules:
|
|
145
|
-
sessionStateKnown:
|
|
154
|
+
runningSessions: running.count,
|
|
155
|
+
armedSchedules: runtime.armedSchedules,
|
|
156
|
+
sessionStateKnown: true,
|
|
146
157
|
})
|
|
147
158
|
// No unconditional emit here: real changes already emit through the
|
|
148
159
|
// ledger subscription (settles) and the gated power listener above.
|
|
149
|
-
|
|
160
|
+
await this.reconcileExecutions(running.items, runtime.openExecutions)
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
/** Reuse the session list this poll already fetched: one list RPC per tick, not 1 + E. */
|
|
153
|
-
private async reconcileExecutions(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
+
private async reconcileExecutions(
|
|
165
|
+
sessions: readonly SessionSummary[],
|
|
166
|
+
executions: readonly OpenExecutionReference[],
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
for (const execution of executions) {
|
|
169
|
+
if (execution.sessionId === undefined) continue
|
|
170
|
+
try {
|
|
171
|
+
const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions)
|
|
172
|
+
if (result.outcome === 'pending') continue
|
|
173
|
+
this.ledger.settle(execution.taskId, execution.executionId, result.outcome, 'error' in result ? result.error : undefined)
|
|
174
|
+
} catch {
|
|
175
|
+
// A transient inspection failure never settles a running execution.
|
|
164
176
|
}
|
|
165
177
|
}
|
|
166
178
|
}
|
|
@@ -175,22 +187,15 @@ export class TaskBoardHostService {
|
|
|
175
187
|
this.ledger.skipMissed(now)
|
|
176
188
|
return
|
|
177
189
|
}
|
|
178
|
-
for (const
|
|
179
|
-
if (task.archivedAt !== undefined) continue
|
|
180
|
-
const schedule = task.schedule
|
|
181
|
-
if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
|
|
190
|
+
for (const schedule of this.ledger.dueSchedules(now)) {
|
|
182
191
|
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
|
|
183
|
-
const opened = this.ledger.openScheduled(
|
|
192
|
+
const opened = this.ledger.openScheduled(schedule.taskId, next, now)
|
|
184
193
|
if (opened !== undefined) this.scheduleLaunch(opened)
|
|
185
194
|
}
|
|
186
195
|
}
|
|
187
196
|
|
|
188
197
|
private armedSchedules(): number {
|
|
189
|
-
return this.ledger.
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
private hasOpenExecutions(): boolean {
|
|
193
|
-
return this.ledger.state().tasks.some(task => task.executions.some(execution => execution.endedAt === undefined))
|
|
198
|
+
return this.ledger.armedScheduleCount()
|
|
194
199
|
}
|
|
195
200
|
|
|
196
201
|
private scheduleLaunch(opened: OpenedRun): void {
|