dsh-foreman-node 0.2.0 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ ## 0.2.1 — 2026-11-08
2
+
3
+ ### 修复:任务跑完了,却没有回报给 hub
4
+
5
+ 真机上一次派单:设备做了 30 步、37 次工具调用、写了 2808 字的完整答案,**一次都没调用 `foreman_report`**。
6
+ hub 因此一直停在 `running`,面板上那张工单永远不结束,人也收不到结果。
7
+
8
+ 两个原因,都修了:
9
+
10
+ 1. **任务文本里没有说要回报。** 设备收到的只有标题、请求原文和附件说明,模型自然当成普通任务,
11
+ 做完就结束。现在每张派单的末尾都有一段说明:`foreman_report` 是这个任务到达人的唯一途径,
12
+ 答在对话里不算完成。
13
+ 2. **只靠模型自觉不可靠。** 加了一层兜底:一轮结束而还没有回报时,插件自动补一条 `ready`,
14
+ 摘要取会话里最后一段模型输出。工具被正确调用过就不会重复补。
15
+
16
+ 兜底是真的兜底——任何一轮正常结束都会触发它,所以即使模型完全无视那段说明,任务也不会挂死。
17
+
18
+ ### 加固
19
+
20
+ - 会话事件监听注册失败时(宿主不提供 `ctx.effect`),仍然会挂上回报工具:
21
+ 没有回报通道比“看不见轮次结束”严重得多。
22
+
1
23
  ## 0.2.0 — 2026-11-07
2
24
 
3
25
  ### 新增:配置页,设备绑定不用再碰命令行
package/lib/index.d.ts CHANGED
@@ -344,6 +344,8 @@ declare class JobSessions {
344
344
  private readonly jobs;
345
345
  /** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
346
346
  private readonly reported;
347
+ /** Assignments that have actually reported; a turn ending without one is closed out by `watchTurnEnd`. */
348
+ private readonly reports;
347
349
  constructor(ctx: Context);
348
350
  /**
349
351
  * Resolve or create the `job-<assignmentId>` Session in the given directory, attach its `foreman_report` tool, and
@@ -372,6 +374,17 @@ declare class JobSessions {
372
374
  forget(assignmentId: string): void;
373
375
  /** Attach the `foreman_report` tool to a job Session, disposed with it. */
374
376
  reportTools(agent: Agent, assignmentId: string, onReport: (assignmentId: string, report: DeviceReport) => void): void;
377
+ /**
378
+ * Report for a job that ended without calling the tool.
379
+ *
380
+ * Telling the model to report is not a guarantee: on a real deployment a device did the work, wrote a complete
381
+ * answer, and never called `foreman_report`, so the hub waited at `running` forever. The tool alone leaves that
382
+ * failure open, so a turn that ends with no report is closed out here — `ready`, with the last thing the model said
383
+ * as the summary. A person still gets the result; the ledger still learns the job ended.
384
+ */
385
+ private watchTurnEnd;
386
+ /** The last text the assistant said in a Session, read from its stored history. */
387
+ private lastText;
375
388
  }
376
389
  /** Version reported in the hello, so the hub can tell which device build is talking. */
377
390
  declare const VERSION = "0.2.0";
package/lib/index.js CHANGED
@@ -877,6 +877,8 @@ var JobSessions = class {
877
877
  jobs = /* @__PURE__ */ new Map();
878
878
  /** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
879
879
  reported = /* @__PURE__ */ new Set();
880
+ /** Assignments that have actually reported; a turn ending without one is closed out by `watchTurnEnd`. */
881
+ reports = /* @__PURE__ */ new Set();
880
882
  constructor(ctx) {
881
883
  this.ctx = ctx;
882
884
  }
@@ -982,7 +984,12 @@ var JobSessions = class {
982
984
  /** Attach the `foreman_report` tool to a job Session, disposed with it. */
983
985
  reportTools(agent, assignmentId, onReport) {
984
986
  const registrar = agent.ctx.tools;
985
- const tool = foremanReportTool({ report: onReport }, assignmentId);
987
+ const reported = this.reports;
988
+ const report = (id, deviceReport) => {
989
+ reported.add(id);
990
+ onReport(id, deviceReport);
991
+ };
992
+ const tool = foremanReportTool({ report }, assignmentId);
986
993
  registrar.register({
987
994
  name: tool.name,
988
995
  description: tool.description,
@@ -999,6 +1006,62 @@ var JobSessions = class {
999
1006
  },
1000
1007
  execute: (args, execution) => tool.execute(args, execution)
1001
1008
  });
1009
+ this.watchTurnEnd(agent, assignmentId, report);
1010
+ }
1011
+ /**
1012
+ * Report for a job that ended without calling the tool.
1013
+ *
1014
+ * Telling the model to report is not a guarantee: on a real deployment a device did the work, wrote a complete
1015
+ * answer, and never called `foreman_report`, so the hub waited at `running` forever. The tool alone leaves that
1016
+ * failure open, so a turn that ends with no report is closed out here — `ready`, with the last thing the model said
1017
+ * as the summary. A person still gets the result; the ledger still learns the job ended.
1018
+ */
1019
+ watchTurnEnd(agent, assignmentId, report) {
1020
+ if (typeof agent.ctx.effect !== "function") return;
1021
+ agent.ctx.effect(() => {
1022
+ agent.ctx.on("session/event", (session, event) => {
1023
+ if (session.id !== agent.session.id || event.type !== "turn/end") return;
1024
+ if (this.reports.has(assignmentId)) return;
1025
+ this.lastText(agent).then((text) => {
1026
+ if (this.reports.has(assignmentId)) return;
1027
+ report(assignmentId, {
1028
+ state: "ready",
1029
+ summary: text === "" ? "the job finished without a report" : text.slice(0, 600)
1030
+ });
1031
+ }, () => {
1032
+ if (this.reports.has(assignmentId)) return;
1033
+ report(assignmentId, {
1034
+ state: "ready",
1035
+ summary: "the job finished without a report"
1036
+ });
1037
+ });
1038
+ });
1039
+ return () => {};
1040
+ });
1041
+ }
1042
+ /** The last text the assistant said in a Session, read from its stored history. */
1043
+ async lastText(agent) {
1044
+ try {
1045
+ var _usingCtx3 = _usingCtx();
1046
+ await this.ctx.sessionPersistence.flush();
1047
+ const handle = _usingCtx3.a(await this.ctx.sessionPersistence.open(agent.id, "read"));
1048
+ let found = "";
1049
+ for (let offset = 0;;) {
1050
+ const { events } = await handle.read(offset, 512);
1051
+ if (events.length === 0) break;
1052
+ for (const event of events) {
1053
+ if (event.type !== "assistant/message") continue;
1054
+ const text = (event.data?.message?.content ?? []).filter((part) => part?.type === "text").map((part) => part.text ?? "").join(" ").trim();
1055
+ if (text !== "") found = text;
1056
+ }
1057
+ offset += events.length;
1058
+ }
1059
+ return found;
1060
+ } catch (_) {
1061
+ _usingCtx3.e = _;
1062
+ } finally {
1063
+ await _usingCtx3.d();
1064
+ }
1002
1065
  }
1003
1066
  };
1004
1067
  // src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-foreman-node",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Device side of dsh-foreman: connects out to a foreman hub, takes a job into a local DSH session, reports back. No ledger, no policy, no database.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/src/session.ts CHANGED
@@ -60,6 +60,8 @@ export class JobSessions {
60
60
  private readonly jobs = new Map<string, string>()
61
61
  /** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
62
62
  private readonly reported = new Set<string>()
63
+ /** Assignments that have actually reported; a turn ending without one is closed out by `watchTurnEnd`. */
64
+ private readonly reports = new Set<string>()
63
65
 
64
66
  constructor(private readonly ctx: Context) {}
65
67
 
@@ -147,10 +149,64 @@ export class JobSessions {
147
149
  /** Attach the `foreman_report` tool to a job Session, disposed with it. */
148
150
  reportTools(agent: Agent, assignmentId: string, onReport: (assignmentId: string, report: DeviceReport) => void): void {
149
151
  const registrar = agent.ctx.tools
150
- const tool = foremanReportTool({ report: onReport }, assignmentId)
152
+ // Wrap the reporter so this Session's own turn ending can tell whether a report ever went out.
153
+ const reported = this.reports
154
+ const report = (id: string, deviceReport: DeviceReport): void => { reported.add(id); onReport(id, deviceReport) }
155
+ const tool = foremanReportTool({ report }, assignmentId)
151
156
  registrar.register({ name: tool.name, description: tool.description, parameters: tool.parameters,
152
157
  output: { schema: { type: 'object', additionalProperties: true },
153
158
  render: (_args: unknown, value: unknown) => [{ type: 'text' as const, text: JSON.stringify(value) }] },
154
159
  execute: (args: unknown, execution: { signal: AbortSignal }) => tool.execute(args as Record<string, unknown>, execution) })
160
+ this.watchTurnEnd(agent, assignmentId, report)
161
+ }
162
+
163
+ /**
164
+ * Report for a job that ended without calling the tool.
165
+ *
166
+ * Telling the model to report is not a guarantee: on a real deployment a device did the work, wrote a complete
167
+ * answer, and never called `foreman_report`, so the hub waited at `running` forever. The tool alone leaves that
168
+ * failure open, so a turn that ends with no report is closed out here — `ready`, with the last thing the model said
169
+ * as the summary. A person still gets the result; the ledger still learns the job ended.
170
+ */
171
+ private watchTurnEnd(agent: Agent, assignmentId: string, report: (id: string, r: DeviceReport) => void): void {
172
+ // The watcher is a safety net, not part of the tool: a host that cannot register it must still get the tool,
173
+ // because a job with no reporting channel at all is worse than one whose turn end is not observed.
174
+ if (typeof agent.ctx.effect !== 'function') return
175
+ agent.ctx.effect(() => {
176
+ const dispose = agent.ctx.on('session/event', (session, event) => {
177
+ if (session.id !== agent.session.id || event.type !== 'turn/end') return
178
+ if (this.reports.has(assignmentId)) return
179
+ // Reading the history is best effort: an unreadable history still closes the job out, because leaving the
180
+ // hub at `running` forever is the worse failure.
181
+ void this.lastText(agent).then(text => {
182
+ if (this.reports.has(assignmentId)) return
183
+ report(assignmentId, { state: 'ready', summary: text === '' ? 'the job finished without a report' : text.slice(0, 600) })
184
+ }, () => {
185
+ if (this.reports.has(assignmentId)) return
186
+ report(assignmentId, { state: 'ready', summary: 'the job finished without a report' })
187
+ })
188
+ })
189
+ return () => { void dispose }
190
+ })
191
+ }
192
+
193
+ /** The last text the assistant said in a Session, read from its stored history. */
194
+ private async lastText(agent: Agent): Promise<string> {
195
+ await this.ctx.sessionPersistence.flush()
196
+ await using handle = await this.ctx.sessionPersistence.open(agent.id, 'read')
197
+ let found = ''
198
+ for (let offset = 0; ;) {
199
+ const { events } = await handle.read(offset, 512)
200
+ if (events.length === 0) break
201
+ for (const event of events) {
202
+ if (event.type !== 'assistant/message') continue
203
+ const parts = (event.data as unknown as { message?: { content?: readonly { type?: string; text?: string }[] } })
204
+ ?.message?.content ?? []
205
+ const text = parts.filter(part => part?.type === 'text').map(part => part.text ?? '').join(' ').trim()
206
+ if (text !== '') found = text
207
+ }
208
+ offset += events.length
209
+ }
210
+ return found
155
211
  }
156
212
  }