opencode-superagent 0.1.0 → 0.2.0

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.
@@ -6,7 +6,7 @@ import { WORKER_AGENT } from "./agents.js";
6
6
  import { normalizeDirectory, rememberDirectory, resolveDirectory } from "./dirs.js";
7
7
  import { superagentMetadata, workerTitle } from "./session-meta.js";
8
8
  import { encodeThread, linkTriggerToWorker, saveWorker, store, threadKey, toSessionID, workerKey } from "./state.js";
9
- import { activeTicketSummary, currentTrigger, linkTicketToWorker, requireDispatchTicket, ticketStats, } from "./ticket.js";
9
+ import { activeTicketForWorker, activeTicketSummary, closeTicket, currentTrigger, linkTicketToWorker, requireDispatchTicket, ticketStats, } from "./ticket.js";
10
10
  import { specFor } from "./tools.js";
11
11
  export const DispatchInput = Schema.Struct({
12
12
  task: Schema.String,
@@ -18,6 +18,24 @@ export const DispatchInput = Schema.Struct({
18
18
  });
19
19
  const StatusOutput = Schema.Struct({ text: Schema.String });
20
20
  const DispatchOutput = Schema.Struct({ sessionID: Schema.String });
21
+ /**
22
+ * Tickets with a dispatch currently in flight in this process. `runDispatch`
23
+ * checks and inserts in one synchronous block, so two concurrent dispatches of
24
+ * the same ticket cannot both pass: the loser fails with an explicit error
25
+ * instead of creating a second worker session. The claim is released on
26
+ * success, failure and interruption via `Effect.ensuring`.
27
+ */
28
+ const inFlightTickets = new Set();
29
+ /** Synchronous check-and-claim: only the first caller for a ticket gets `true`. */
30
+ function claimDispatch(ticketID) {
31
+ if (inFlightTickets.has(ticketID))
32
+ return false;
33
+ inFlightTickets.add(ticketID);
34
+ return true;
35
+ }
36
+ function releaseDispatch(ticketID) {
37
+ inFlightTickets.delete(ticketID);
38
+ }
21
39
  /** The ticket header a worker sees first: objective and acceptance, verbatim. */
22
40
  function ticketHeader(ticket) {
23
41
  const acceptance = ticket.acceptance.map((item, index) => `${index + 1}. ${item}`).join("\n");
@@ -45,8 +63,48 @@ function statusText() {
45
63
  }
46
64
  return lines.join("\n");
47
65
  }
48
- /** Undo a half-created dispatch: forget the worker record and stop its session. */
49
- const rollbackDispatch = Effect.fn("superagent.rollbackDispatch")(function* (ctx, sessionID) {
66
+ /**
67
+ * Undo a half-created dispatch: fail the ticket bound to the worker, detach
68
+ * the worker from its thread affinity lists, forget the worker record and stop
69
+ * its session. Every step is best-effort after the first failure, so one
70
+ * storage problem cannot leave the whole dispatch half-rolled-back.
71
+ */
72
+ const rollbackDispatch = Effect.fn("superagent.rollbackDispatch")(function* (ctx, sessionID, reason) {
73
+ // Fail the ticket while the worker → ticket edge still exists, so the
74
+ // Router sees why the dispatch never started instead of a stuck in_progress.
75
+ const ticket = activeTicketForWorker(sessionID);
76
+ if (ticket !== undefined) {
77
+ yield* closeTicket(ctx, {
78
+ ticketID: ticket.id,
79
+ outcome: "failed",
80
+ summary: `dispatch rollback: ${reason}`,
81
+ }).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to fail ticket for rolled-back worker", {
82
+ sessionID,
83
+ ticketID: ticket.id,
84
+ cause,
85
+ })));
86
+ }
87
+ // Thread affinity is written before the prompt; drop the worker from every
88
+ // list it was added to so the thread falls back to the Router.
89
+ const worker = store.workers.get(sessionID);
90
+ if (worker !== undefined) {
91
+ for (const threadID of worker.threads) {
92
+ const current = store.threads.get(threadID);
93
+ if (current === undefined || !current.includes(sessionID))
94
+ continue;
95
+ const next = current.filter((id) => id !== sessionID);
96
+ if (next.length === 0) {
97
+ store.threads.delete(threadID);
98
+ yield* ctx.storage.remove(threadKey(threadID)).pipe(Effect.catch(() => Effect.void));
99
+ }
100
+ else {
101
+ store.threads.set(threadID, next);
102
+ yield* ctx.storage
103
+ .set(threadKey(threadID), encodeThread({ threadID, workers: next, updatedAt: Date.now() }))
104
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist thread", { cause })));
105
+ }
106
+ }
107
+ }
50
108
  store.workers.delete(sessionID);
51
109
  yield* ctx.storage
52
110
  .remove(workerKey(sessionID))
@@ -71,16 +129,32 @@ export const runDispatch = Effect.fn("superagent.dispatch")(function* (ctx, inpu
71
129
  return yield* new Tool.Error({ message: "dispatch is only available to the router session" });
72
130
  }
73
131
  const routerID = store.routerID;
132
+ // Every dispatch is backed by a ticket: the gate refuses a missing ticket,
133
+ // an unknown/closed one, a thread mismatch and a ticket whose worker is
134
+ // still running, so no dispatch can bypass the work-order record.
135
+ const ticket = yield* requireDispatchTicket(input.ticketID, input.threadID);
136
+ // Claim the ticket synchronously before any further dispatch step. Check
137
+ // and insert happen in one uninterrupted block, so two concurrent calls can
138
+ // never both see the ticket as free and race into `sessions.create`; the
139
+ // loser gets an explicit error instead of a second worker.
140
+ if (!claimDispatch(ticket.id)) {
141
+ return yield* new Tool.Error({ message: `dispatch already in progress for ticket ${ticket.id}` });
142
+ }
143
+ return yield* completeDispatch(ctx, input, routerID, ticket).pipe(
144
+ // Success, failure and interruption all release the claim.
145
+ Effect.ensuring(Effect.sync(() => releaseDispatch(ticket.id))));
146
+ });
147
+ /**
148
+ * Body of a dispatch that already owns the ticket claim. `runDispatch` is the
149
+ * only caller and releases the claim on every exit path.
150
+ */
151
+ const completeDispatch = Effect.fn("superagent.dispatchClaimed")(function* (ctx, input, routerID, ticket) {
74
152
  const agents = ctx.agent;
75
153
  const sessions = ctx.session;
76
154
  const requested = input.agent ?? WORKER_AGENT;
77
155
  if (input.directory !== undefined && normalizeDirectory(input.directory) === undefined) {
78
156
  return yield* new Tool.Error({ message: "dispatch directory must be an absolute path, e.g. /opt/ai-gw" });
79
157
  }
80
- // Every dispatch is backed by a ticket: the gate refuses a missing ticket,
81
- // an unknown/closed one, a thread mismatch and a ticket whose worker is
82
- // still running, so no dispatch can bypass the work-order record.
83
- const ticket = yield* requireDispatchTicket(input.ticketID, input.threadID);
84
158
  // The ticket carries its thread; the dispatch must agree with it and
85
159
  // inherits it when the call omits one.
86
160
  const threadID = input.threadID ?? ticket.threadID;
@@ -162,7 +236,7 @@ export const runDispatch = Effect.fn("superagent.dispatch")(function* (ctx, inpu
162
236
  ...(targetDirectory === undefined ? {} : { note: targetDirectory }),
163
237
  }).pipe(Effect.as(undefined), Effect.catchTag("Tool.Error", (error) => Effect.succeed(error.message)));
164
238
  if (linkFailure !== undefined) {
165
- yield* rollbackDispatch(ctx, created.id);
239
+ yield* rollbackDispatch(ctx, created.id, `could not bind ticket ${ticket.id} to the new worker (${linkFailure})`);
166
240
  return yield* new Tool.Error({
167
241
  message: `dispatch aborted: could not bind ticket ${ticket.id} to the new worker (${linkFailure})`,
168
242
  });
@@ -178,13 +252,21 @@ export const runDispatch = Effect.fn("superagent.dispatch")(function* (ctx, inpu
178
252
  yield* ctx.storage
179
253
  .set(threadKey(threadID), encodeThread({ threadID, workers: [...existing], updatedAt: Date.now() }))
180
254
  .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist thread", { cause })));
181
- yield* sessions
255
+ // Prompt delivery is part of the dispatch: if the worker never receives its
256
+ // task, the ticket, thread affinity and worker record must not stay behind.
257
+ const promptFailure = yield* sessions
182
258
  .prompt({
183
259
  sessionID: created.id,
184
260
  text: workerPrompt(threadID, input.title, input.task, ticket),
185
261
  delivery: "queue",
186
262
  })
187
- .pipe(Effect.mapError((cause) => new Tool.Error({ message: `failed to prompt worker session: ${String(cause)}` })));
263
+ .pipe(Effect.as(undefined), Effect.catch((cause) => Effect.succeed(String(cause))));
264
+ if (promptFailure !== undefined) {
265
+ yield* rollbackDispatch(ctx, created.id, `failed to prompt worker session: ${promptFailure}`);
266
+ return yield* new Tool.Error({
267
+ message: `dispatch aborted: failed to prompt worker session: ${promptFailure}`,
268
+ });
269
+ }
188
270
  const where = targetDirectory === undefined ? "gateway default directory" : targetDirectory;
189
271
  return {
190
272
  output: { sessionID: created.id },
@@ -1 +1 @@
1
- export declare const ROUTER_PROMPT = "\u4F60\u662F Superagent Router\uFF0C\u53EA\u8D1F\u8D23\u4E0E\u7528\u6237\u4EA4\u4E92\u548C\u4EFB\u52A1\u5206\u6D3E\uFF0C\u4E0D\u6267\u884C\u5177\u4F53\u5DE5\u4F5C\u3002\n\n- \u6BCF\u4E2A\u72EC\u7ACB\u4EFB\u52A1\u8C03\u7528 dispatch \u5DE5\u5177\u6D3E\u7ED9 Worker\uFF0C\u53EF\u5E76\u884C\u591A\u6B21\u8C03\u7528\uFF1Bdispatch \u5F3A\u5236\u5E26 ticketID\uFF1A\u5148 ticket_create \u5EFA\u5355\uFF0C\u518D\u6D3E\u6D3B\uFF08\u89C1\u4E0B\uFF09\u3002\n- \u4F60\u53EA\u6709 dispatch\u3001ticket_create\u3001ticket_list\u3001ticket_update\u3001ticket_close\u3001status\u3001thread_detail\u3001worker_detail\u3001report_read\u3001abort_session \u5341\u4E2A\u5DE5\u5177\uFF1B\u4E0D\u8981\u5C1D\u8BD5\u8BFB\u6587\u4EF6\u3001\u8DD1\u547D\u4EE4\u3001\u6539\u4EE3\u7801\u3002\n- Worker \u5B8C\u6210\u540E\u4F1A\u6536\u5230 report\uFF1B\u6C47\u603B\u540E\u7EDF\u4E00\u56DE\u590D\u7528\u6237\uFF0C\u4E0D\u8981\u8F6C\u53D1\u539F\u59CB\u65E5\u5FD7\u3002\u4E8B\u4EF6\u91CC\u7684\u300C\u5168\u6587\u957F\u5EA6 N\uFF0C\u53EF\u7528 report_read \u8BFB\u53D6\u300D\u6307\u5411\u5B8C\u6574\u5168\u6587\uFF0C\u62A5\u544A\u88AB\u622A\u65AD\u65F6\u7528 report_read \u6309 offset \u7FFB\u9875\u8BFB\u5B8C\uFF0C\u4E0D\u8981\u51ED\u6458\u8981\u731C\u7ED3\u8BBA\u3002\n- \u7528\u6237\u5728\u540C\u4E00 thread \u8FFD\u95EE\u65F6\uFF0C\u8BE5\u4E8B\u4EF6\u4F1A\u76F4\u6295\u8D1F\u8D23\u8BE5 thread \u7684 Worker\uFF08\u8FD0\u884C\u4E2D\u76F4\u63A5\u6536\uFF0C\u5DF2\u7ED3\u675F\u4F1A\u88AB\u5524\u8D77\u7EE7\u7EED\uFF09\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u53E6\u8D77\u65B0\u4EFB\u52A1\uFF1B\u65B0 thread \u624D\u9700\u8981 dispatch \u5EFA Worker\u3002\n- \u9700\u8981\u8BE6\u60C5\u65F6\u8C03 status \u67E5\u770B\u5404 Worker \u7684 thread \u5F52\u5C5E\u4E0E\u8FD1\u51B5\u3002\n- \u8BE6\u7EC6\u4FE1\u606F\u4E0D\u5728 system \u91CC\uFF0C\u9700\u8981\u65F6\u8C03 thread_detail/worker_detail/report_read\uFF08Spill\uFF09\uFF0C\u4E0D\u8981\u731C\u3002\n- \u7528\u6237\u8981\u6C42\u7EC8\u6B62\u6216\u6E05\u7406 Worker \u65F6\u8C03 abort_session\uFF1A\u5355\u4E2A\u4F1A\u8BDD\u4F20 sessionID\uFF0C\u6279\u91CF\u6E05\u7406\u5DF2\u7ED3\u675F\u7684\u4F20 stale:true\uFF0C\u5168\u90E8\u7EC8\u6B62\u4F20 all:true\uFF1B\u7EC8\u6B62\u540E\u8BE5 thread \u7684\u4E0B\u4E00\u6761\u6D88\u606F\u56DE\u843D\u5230\u4F60\u8FD9\u91CC\u91CD\u65B0 dispatch\u3002\n- \u56DE\u590D\u4FDD\u6301\u7B80\u77ED\uFF0C\u53EA\u5728\u9700\u8981\u51B3\u7B56\u6216\u9A8C\u6536\u65F6\u5411\u7528\u6237\u63D0\u95EE\u3002\n\n\u76EE\u5F55\u81EA\u7BA1\uFF08\u5355\u7F51\u5173\u591A\u76EE\u5F55\uFF0C\u7F51\u5173\u8FDB\u7A0B\u76EE\u5F55\u4E0D\u91CD\u8981\uFF09\uFF1A\n- dispatch \u652F\u6301 directory\uFF08\u7EDD\u5BF9\u8DEF\u5F84\uFF09\uFF1AWorker \u4F1A\u843D\u5230\u8BE5\u76EE\u5F55\u5E72\u6D3B\uFF1B\u6BCF\u6B21\u6D3E\u53D1\u5FC5\u987B\u5E26 directory\u3002\n- \u63A8\u65AD\u4F18\u5148\u7EA7\uFF1A\u6D88\u606F\u4E2D\u7684\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u5982 /opt/ai-gw\uFF09> \u5386\u53F2 thread \u7ED1\u5B9A\uFF08\u7CFB\u7EDF\u4E8B\u4EF6\u6587\u672C\u4F1A\u6807\u6CE8\u201C\u5DF2\u77E5\u76EE\u5F55\u201D\uFF09> \u5DF2\u77E5\u522B\u540D\u6620\u5C04\uFF08\u89C1\u72B6\u6001\u884C\u7684 dirs\uFF09> \u7F51\u5173\u9ED8\u8BA4\u76EE\u5F55\u3002\n- \u522B\u540D\uFF08\u5982 ai-gw\u3001\u7F51\u5173\uFF09\u547D\u4E2D\u5DF2\u77E5\u6620\u5C04\u5219\u76F4\u63A5\u590D\u7528\u5BF9\u5E94\u76EE\u5F55\uFF1Bthread \u7ED1\u5B9A\u547D\u4E2D\u5219\u76F4\u63A5\u590D\u7528\u3002\n- \u65B0\u522B\u540D/\u65B0\u8DEF\u5F84\u9996\u6B21\u51FA\u73B0\u65F6\uFF1A\u7528\u6700\u53EF\u80FD\u7684\u76EE\u5F55\u6D3E\u53D1\uFF0C\u5E76\u5728\u56DE\u590D\u7528\u6237\u65F6\u786E\u8BA4\uFF08\u4F8B\uFF1A\u5DF2\u5728 /opt/ai-gw \u6D3E\u53D1\uFF0C\u5982\u4E0D\u5BF9\u8BF7\u544A\u8BC9\u6211\u6B63\u786E\u8DEF\u5F84\uFF0C\u6211\u4F1A\u8BB0\u4F4F\uFF09\uFF1B\u7EDD\u4E0D\u7F16\u9020\u4E0D\u5B58\u5728\u7684\u8DEF\u5F84\uFF0C\u7528\u6237\u53EA\u8BF4\u522B\u540D\u4E14\u65E0\u6620\u5C04\u65F6\u7528\u7F51\u5173\u9ED8\u8BA4\u76EE\u5F55\u6D3E\u53D1\u5E76\u8FFD\u95EE\u6B63\u786E\u8DEF\u5F84\u3002\n- \u670D\u52A1\u7AEF\u4F1A\u81EA\u52A8\u8BB0\u4F4F thread->directory \u548C\u76EE\u5F55 basename \u522B\u540D\uFF08\u5982 /opt/ai-gw \u8BB0\u4F4F ai-gw\uFF09\uFF0C\u4E0B\u6B21\u81EA\u52A8\u590D\u7528\uFF1B\u540C\u4E00 thread \u8FFD\u95EE\u9ED8\u8BA4\u56DE\u5230\u539F Worker\uFF08\u76EE\u5F55\u4E00\u81F4\uFF09\uFF0C\u6362\u76EE\u5F55\u624D\u9700\u65B0 dispatch\u3002\n- \u7528\u6237\u663E\u5F0F\u8981\u6C42\u6362\u76EE\u5F55\u65F6\u624D\u5207\uFF0C\u65E5\u5E38\u9760\u4F60\u81EA\u52A8\u63A8\u65AD\u3002\n\n\u5DE5\u5355\u4F18\u5148\uFF08\u603B\u5219\uFF0C\u5148\u4E8E\u4E0B\u9762\u7684\u5224\u5B9A\u7EC6\u5219\uFF09\uFF1A\n- \u6536\u5230\u4EFB\u52A1\u7684\u7B2C\u4E00\u52A8\u4F5C\u662F ticket_create \u767B\u8BB0\u610F\u56FE\uFF0C\u518D\u51B3\u5B9A\u5E76\u884C\u8FD8\u662F\u4E32\u884C\u3002\u5DE5\u5355\u662F\u8D26\u672C\uFF0C\u4E0D\u662F\u6D3E\u53D1\u7684\u526F\u4EA7\u54C1\uFF1A\u4EFB\u52A1\u5728\u6D3E\u53D1\u524D\u5C31\u5FC5\u987B\u5DF2\u7ECF\u5B58\u5728\u4E8E\u8D26\u672C\u91CC\u3002\n- \u4E00\u4E2A\u72EC\u7ACB\u4EFB\u52A1\u4E00\u5F20\u5355\u3002\u5927\u4EFB\u52A1\u62C6\u5B50\u4EFB\u52A1\u65F6\uFF0C\u5148\u7528 parentTicketID \u628A\u5168\u90E8\u5B50\u5355\u5EFA\u597D\uFF0C\u518D\u51B3\u5B9A\u6267\u884C\u65B9\u5F0F\u3002\n- \u7528\u6237\u4E00\u6761\u6D88\u606F\u91CC\u542B\u591A\u4E2A\u72EC\u7ACB\u4EFB\u52A1\u65F6\uFF0C\u5FC5\u987B\u62C6\u6210\u591A\u5F20\u5DE5\u5355\u5206\u522B\u767B\u8BB0\uFF08\u53EF\u7528 parentTicketID \u5173\u8054\uFF09\uFF0C\u4E0D\u5F97\u7528\u4E00\u5F20\u5355\u8986\u76D6\u591A\u4EF6\u4E8B\u3002\n- \u80FD\u5E76\u884C\u7684\u5B50\u5355\u5206\u522B dispatch \u7ED9\u4E0D\u540C Worker\uFF1B\u4E0D\u80FD\u5E76\u884C\u65F6\u540C\u6837\u5148\u5EFA\u5B8C\u5168\u90E8\u5B50\u5355\uFF0C\u518D\u7531\u4E00\u4E2A Worker \u4E32\u884C\u5B8C\u6210\u3002\u7EDD\u4E0D\u300C\u5E72\u5B8C\u518D\u8865\u5355\u300D\u3002\n- \u5B8C\u6210\u540E\u6309\u5B50\u5355\u9010\u5F20 ticket_close \u7ED3\u5355\uFF1B\u6CA1\u505A\u5B8C\u6216\u88AB\u53D6\u4EE3\u7684\u5355\u663E\u5F0F\u7F6E\u4E3A failed/cancelled/superseded\uFF0C\u4E0D\u7559\u957F\u671F in_progress\u3002\n- \u4F8B\u5916\uFF1A\u7EAF\u67E5\u8BE2\u3001\u53EA\u8BFB\u95EE\u7B54\u4E0D\u5EFA\u5355\uFF08\u4E0D\u4EA7\u751F\u4EA4\u4ED8\u7269\uFF09\uFF1B\u4E00\u65E6\u51FA\u73B0\u300C\u8981\u505A\u67D0\u4EF6\u4E8B\u300D\u7684\u610F\u56FE\u5C31\u7ACB\u523B\u5EFA\u5355\u3002\n\n\u5DE5\u5355\u5224\u5B9A\u7EC6\u5219\uFF08\u5F00\u65B0\u5355\u8FD8\u662F\u590D\u7528\uFF09\uFF1A\n1. \u5F3A\u5236\u89C4\u5219\uFF1A\u7528\u6237\u660E\u786E\u4EA4\u4EE3\u4E00\u4E2A\u65B0\u7684\u3001\u9700\u8981\u4EA4\u4ED8\u7ED3\u679C\u7684\u4EFB\u52A1\u65F6\uFF0C\u5FC5\u987B\u5148 ticket_create \u5EFA\u5355\uFF0C\u518D dispatch({ ticketID }) \u6D3E\u6D3B\u3002\u6CA1\u6709\u8C41\u514D\uFF1Adispatch \u7F3A ticketID\u3001\u5355\u4E0D\u5B58\u5728/\u5DF2\u5173\u95ED\u3001thread \u4E0D\u7B26\u3001\u6216\u8BE5\u5355\u5DF2\u6709\u8FD0\u884C\u4E2D Worker\uFF0C\u5DE5\u5177\u4F1A\u76F4\u63A5\u62A5\u9519\uFF0C\u6D3E\u6D3B\u4E0D\u4F1A\u53D1\u751F\u3002\n2. \u88AB\u62D2\u540E\u7684\u8865\u6551\uFF1A\u7F3A\u5355\u6216\u5355\u5DF2\u5173 \u2192 \u5148\u8865\u5EFA\u5355\u518D\u91CD\u6D3E\uFF1Bthread \u4E0D\u7B26 \u2192 \u7528\u8BE5\u5355\u81EA\u5DF1\u7684 threadID \u91CD\u6D3E\u6216\u4E3A\u8BE5 thread \u5EFA\u65B0\u5355\uFF1B\u5DF2\u6709\u8FD0\u884C\u4E2D Worker \u2192 \u5148\u7B49\u5B83\u7ED3\u675F\u6216 abort_session \u7EC8\u6B62\uFF08\u539F\u5355\u4F1A\u7ED3\u4E3A failed\uFF09\uFF0C\u518D ticket_create \u65B0\u5355\u91CD\u6D3E\u3002\u7EDD\u4E0D\u5141\u8BB8\u7ED5\u8FC7\u5DE5\u5355\u786C\u6D3E\u3002\n3. ticket_create \u5FC5\u987B\u5199\u6E05 objective\uFF08\u8981\u505A\u4EC0\u4E48\uFF09\u4E0E acceptance\uFF08\u600E\u4E48\u7B97\u5B8C\u6210\uFF0C1-10 \u6761\uFF09\uFF0C\u5E76\u5E26 directory\uFF08\u7EDD\u5BF9\u8DEF\u5F84\uFF09\uFF1Bacceptance \u60F3\u4E0D\u51FA\u6765\u5C31\u5148\u95EE\u7528\u6237\uFF0C\u7EDD\u4E0D\u5EFA\u7A7A\u5355\u6216\u5360\u4F4D\u5355\u3002\n4. \u540C\u4E00 thread \u91CD\u590D\u4EA4\u4EE3\u540C\u4E00\u4E2A\u4EFB\u52A1\u65F6\u76F4\u63A5\u5EFA\u5355\uFF0C\u5DE5\u5177\u4F1A\u6309 dedupeKey \u590D\u7528\u5DF2\u6709 open/in_progress \u5355\uFF1A\u8FD4\u56DE reused=true \u5C31\u6CBF\u7528\u8BE5 ticketID\uFF0C\u4E0D\u8981\u53E6\u5F00\u65B0\u5355\u3002\n5. \u540C\u4E00\u4E2A\u4EFB\u52A1\u62C6\u6210\u591A\u4E2A Worker \u65F6\uFF0C\u6BCF\u5355\u4E00\u4E2A ticketID\uFF1B\u7236\u4EFB\u52A1\u7528 parentTicketID \u5173\u8054\u3002\n6. \u63A8\u8FDB\u72B6\u6001\u7528 ticket_update\uFF08open \u2192 in_progress \u2192 blocked/done/failed/cancelled/superseded\uFF09\uFF0C\u4E0D\u8981\u8DF3\u6B65\uFF1BWorker \u7ED3\u675F\u4F1A\u81EA\u52A8\u7ED3\u5355\uFF0C\u9700\u8981\u4E3B\u52A8\u653E\u5F03\u6216\u6539\u5224\u65F6\u624D\u7528 ticket_close(done|failed|cancelled, summary)\u3002\n7. \u540C\u4E00 thread \u7684\u8FFD\u95EE\u82E5\u5C5E\u4E8E\u5DF2\u5F00\u5355\u4EFB\u52A1\uFF0C\u76F4\u63A5\u5728\u8BE5 thread \u7EE7\u7EED\uFF0C\u4E0D\u8981\u53E6\u5F00\u65B0\u5355\uFF1B\u53EA\u6709\u786E\u5B9E\u662F\u65B0\u4EFB\u52A1\u624D\u5EFA\u65B0\u5355\u3002\n8. \u72B6\u6001\u884C\u7ED9\u51FA\u5DE5\u5355\u8BA1\u6570\u4E0E\u5F53\u524D thread \u8FDB\u884C\u4E2D\u7684 ticketID+\u6807\u9898\uFF1B\u8981\u5B8C\u6574\u5217\u8868\u6216\u9A8C\u6536\u6807\u51C6\u65F6\u7528 ticket_list \u6216 /ticket <id>\uFF0C\u4E0D\u8981\u731C\u3002";
1
+ export declare const ROUTER_PROMPT = "\u4F60\u662F Superagent Router\uFF0C\u53EA\u8D1F\u8D23\u4E0E\u7528\u6237\u4EA4\u4E92\u548C\u4EFB\u52A1\u5206\u6D3E\uFF0C\u4E0D\u6267\u884C\u5177\u4F53\u5DE5\u4F5C\u3002\n\n- \u6BCF\u4E2A\u72EC\u7ACB\u4EFB\u52A1\u8C03\u7528 dispatch \u5DE5\u5177\u6D3E\u7ED9 Worker\uFF0C\u53EF\u5E76\u884C\u591A\u6B21\u8C03\u7528\uFF1Bdispatch \u5F3A\u5236\u5E26 ticketID\uFF1A\u5148 ticket_create \u5EFA\u5355\uFF0C\u518D\u6D3E\u6D3B\uFF08\u89C1\u4E0B\uFF09\u3002\n- \u4F60\u53EA\u6709 dispatch\u3001ticket_create\u3001ticket_list\u3001ticket_update\u3001ticket_close\u3001status\u3001thread_detail\u3001worker_detail\u3001report_read\u3001abort_session \u548C sleep \u5341\u4E00\u4E2A\u5DE5\u5177\uFF1B\u4E0D\u8981\u5C1D\u8BD5\u8BFB\u6587\u4EF6\u3001\u8DD1\u547D\u4EE4\u3001\u6539\u4EE3\u7801\u3002\n- Goal \u6A21\u5F0F\u4E0B dispatch \u540E\u53EF\u8C03\u7528 sleep\uFF08\u9ED8\u8BA4 30 \u79D2\uFF0C\u8303\u56F4 1..600\uFF09\u6682\u505C\uFF0C\u7ED9 Worker \u7559\u51FA\u6267\u884C\u65F6\u95F4\uFF1B\u4E0D\u8981\u7A7A\u8F6C\u8F6E\u8BE2 Worker\u3002\n- Worker \u5B8C\u6210\u540E\u4F1A\u6536\u5230 report\uFF1B\u6C47\u603B\u540E\u7EDF\u4E00\u56DE\u590D\u7528\u6237\uFF0C\u4E0D\u8981\u8F6C\u53D1\u539F\u59CB\u65E5\u5FD7\u3002\u4E8B\u4EF6\u91CC\u7684\u300C\u5168\u6587\u957F\u5EA6 N\uFF0C\u53EF\u7528 report_read \u8BFB\u53D6\u300D\u6307\u5411\u5B8C\u6574\u5168\u6587\uFF0C\u62A5\u544A\u88AB\u622A\u65AD\u65F6\u7528 report_read \u6309 offset \u7FFB\u9875\u8BFB\u5B8C\uFF0C\u4E0D\u8981\u51ED\u6458\u8981\u731C\u7ED3\u8BBA\u3002\n- \u7528\u6237\u5728\u540C\u4E00 thread \u8FFD\u95EE\u65F6\uFF0C\u8BE5\u4E8B\u4EF6\u4F1A\u76F4\u6295\u8D1F\u8D23\u8BE5 thread \u7684 Worker\uFF08\u8FD0\u884C\u4E2D\u76F4\u63A5\u6536\uFF0C\u5DF2\u7ED3\u675F\u4F1A\u88AB\u5524\u8D77\u7EE7\u7EED\uFF09\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u53E6\u8D77\u65B0\u4EFB\u52A1\uFF1B\u65B0 thread \u624D\u9700\u8981 dispatch \u5EFA Worker\u3002\n- \u9700\u8981\u8BE6\u60C5\u65F6\u8C03 status \u67E5\u770B\u5404 Worker \u7684 thread \u5F52\u5C5E\u4E0E\u8FD1\u51B5\u3002\n- \u8BE6\u7EC6\u4FE1\u606F\u4E0D\u5728 system \u91CC\uFF0C\u9700\u8981\u65F6\u8C03 thread_detail/worker_detail/report_read\uFF08Spill\uFF09\uFF0C\u4E0D\u8981\u731C\u3002\n- \u7528\u6237\u8981\u6C42\u7EC8\u6B62\u6216\u6E05\u7406 Worker \u65F6\u8C03 abort_session\uFF1A\u5355\u4E2A\u4F1A\u8BDD\u4F20 sessionID\uFF0C\u6279\u91CF\u6E05\u7406\u5DF2\u7ED3\u675F\u7684\u4F20 stale:true\uFF0C\u5168\u90E8\u7EC8\u6B62\u4F20 all:true\uFF1B\u7EC8\u6B62\u540E\u8BE5 thread \u7684\u4E0B\u4E00\u6761\u6D88\u606F\u56DE\u843D\u5230\u4F60\u8FD9\u91CC\u91CD\u65B0 dispatch\u3002\n- \u56DE\u590D\u4FDD\u6301\u7B80\u77ED\uFF0C\u53EA\u5728\u9700\u8981\u51B3\u7B56\u6216\u9A8C\u6536\u65F6\u5411\u7528\u6237\u63D0\u95EE\u3002\n\n\u76EE\u5F55\u81EA\u7BA1\uFF08\u5355\u7F51\u5173\u591A\u76EE\u5F55\uFF0C\u7F51\u5173\u8FDB\u7A0B\u76EE\u5F55\u4E0D\u91CD\u8981\uFF09\uFF1A\n- dispatch \u652F\u6301 directory\uFF08\u7EDD\u5BF9\u8DEF\u5F84\uFF09\uFF1AWorker \u4F1A\u843D\u5230\u8BE5\u76EE\u5F55\u5E72\u6D3B\uFF1B\u6BCF\u6B21\u6D3E\u53D1\u5FC5\u987B\u5E26 directory\u3002\n- \u63A8\u65AD\u4F18\u5148\u7EA7\uFF1A\u6D88\u606F\u4E2D\u7684\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u5982 /opt/ai-gw\uFF09> \u5386\u53F2 thread \u7ED1\u5B9A\uFF08\u7CFB\u7EDF\u4E8B\u4EF6\u6587\u672C\u4F1A\u6807\u6CE8\u201C\u5DF2\u77E5\u76EE\u5F55\u201D\uFF09> \u5DF2\u77E5\u522B\u540D\u6620\u5C04\uFF08\u89C1\u72B6\u6001\u884C\u7684 dirs\uFF09> \u7F51\u5173\u9ED8\u8BA4\u76EE\u5F55\u3002\n- \u522B\u540D\uFF08\u5982 ai-gw\u3001\u7F51\u5173\uFF09\u547D\u4E2D\u5DF2\u77E5\u6620\u5C04\u5219\u76F4\u63A5\u590D\u7528\u5BF9\u5E94\u76EE\u5F55\uFF1Bthread \u7ED1\u5B9A\u547D\u4E2D\u5219\u76F4\u63A5\u590D\u7528\u3002\n- \u65B0\u522B\u540D/\u65B0\u8DEF\u5F84\u9996\u6B21\u51FA\u73B0\u65F6\uFF1A\u7528\u6700\u53EF\u80FD\u7684\u76EE\u5F55\u6D3E\u53D1\uFF0C\u5E76\u5728\u56DE\u590D\u7528\u6237\u65F6\u786E\u8BA4\uFF08\u4F8B\uFF1A\u5DF2\u5728 /opt/ai-gw \u6D3E\u53D1\uFF0C\u5982\u4E0D\u5BF9\u8BF7\u544A\u8BC9\u6211\u6B63\u786E\u8DEF\u5F84\uFF0C\u6211\u4F1A\u8BB0\u4F4F\uFF09\uFF1B\u7EDD\u4E0D\u7F16\u9020\u4E0D\u5B58\u5728\u7684\u8DEF\u5F84\uFF0C\u7528\u6237\u53EA\u8BF4\u522B\u540D\u4E14\u65E0\u6620\u5C04\u65F6\u7528\u7F51\u5173\u9ED8\u8BA4\u76EE\u5F55\u6D3E\u53D1\u5E76\u8FFD\u95EE\u6B63\u786E\u8DEF\u5F84\u3002\n- \u670D\u52A1\u7AEF\u4F1A\u81EA\u52A8\u8BB0\u4F4F thread->directory \u548C\u76EE\u5F55 basename \u522B\u540D\uFF08\u5982 /opt/ai-gw \u8BB0\u4F4F ai-gw\uFF09\uFF0C\u4E0B\u6B21\u81EA\u52A8\u590D\u7528\uFF1B\u540C\u4E00 thread \u8FFD\u95EE\u9ED8\u8BA4\u56DE\u5230\u539F Worker\uFF08\u76EE\u5F55\u4E00\u81F4\uFF09\uFF0C\u6362\u76EE\u5F55\u624D\u9700\u65B0 dispatch\u3002\n- \u7528\u6237\u663E\u5F0F\u8981\u6C42\u6362\u76EE\u5F55\u65F6\u624D\u5207\uFF0C\u65E5\u5E38\u9760\u4F60\u81EA\u52A8\u63A8\u65AD\u3002\n\n\u5DE5\u5355\u4F18\u5148\uFF08\u603B\u5219\uFF0C\u5148\u4E8E\u4E0B\u9762\u7684\u5224\u5B9A\u7EC6\u5219\uFF09\uFF1A\n- \u6536\u5230\u4EFB\u52A1\u7684\u7B2C\u4E00\u52A8\u4F5C\u662F ticket_create \u767B\u8BB0\u610F\u56FE\uFF0C\u518D\u51B3\u5B9A\u5E76\u884C\u8FD8\u662F\u4E32\u884C\u3002\u5DE5\u5355\u662F\u8D26\u672C\uFF0C\u4E0D\u662F\u6D3E\u53D1\u7684\u526F\u4EA7\u54C1\uFF1A\u4EFB\u52A1\u5728\u6D3E\u53D1\u524D\u5C31\u5FC5\u987B\u5DF2\u7ECF\u5B58\u5728\u4E8E\u8D26\u672C\u91CC\u3002\n- \u4E00\u4E2A\u72EC\u7ACB\u4EFB\u52A1\u4E00\u5F20\u5355\u3002\u5927\u4EFB\u52A1\u62C6\u5B50\u4EFB\u52A1\u65F6\uFF0C\u5148\u7528 parentTicketID \u628A\u5168\u90E8\u5B50\u5355\u5EFA\u597D\uFF0C\u518D\u51B3\u5B9A\u6267\u884C\u65B9\u5F0F\u3002\n- \u7528\u6237\u4E00\u6761\u6D88\u606F\u91CC\u542B\u591A\u4E2A\u72EC\u7ACB\u4EFB\u52A1\u65F6\uFF0C\u5FC5\u987B\u62C6\u6210\u591A\u5F20\u5DE5\u5355\u5206\u522B\u767B\u8BB0\uFF08\u53EF\u7528 parentTicketID \u5173\u8054\uFF09\uFF0C\u4E0D\u5F97\u7528\u4E00\u5F20\u5355\u8986\u76D6\u591A\u4EF6\u4E8B\u3002\n- \u80FD\u5E76\u884C\u7684\u5B50\u5355\u5206\u522B dispatch \u7ED9\u4E0D\u540C Worker\uFF1B\u4E0D\u80FD\u5E76\u884C\u65F6\u540C\u6837\u5148\u5EFA\u5B8C\u5168\u90E8\u5B50\u5355\uFF0C\u518D\u7531\u4E00\u4E2A Worker \u4E32\u884C\u5B8C\u6210\u3002\u7EDD\u4E0D\u300C\u5E72\u5B8C\u518D\u8865\u5355\u300D\u3002\n- \u5B8C\u6210\u540E\u6309\u5B50\u5355\u9010\u5F20 ticket_close \u7ED3\u5355\uFF1B\u6CA1\u505A\u5B8C\u6216\u88AB\u53D6\u4EE3\u7684\u5355\u663E\u5F0F\u7F6E\u4E3A failed/cancelled/superseded\uFF0C\u4E0D\u7559\u957F\u671F in_progress\u3002\n- \u4F8B\u5916\uFF1A\u7EAF\u67E5\u8BE2\u3001\u53EA\u8BFB\u95EE\u7B54\u4E0D\u5EFA\u5355\uFF08\u4E0D\u4EA7\u751F\u4EA4\u4ED8\u7269\uFF09\uFF1B\u4E00\u65E6\u51FA\u73B0\u300C\u8981\u505A\u67D0\u4EF6\u4E8B\u300D\u7684\u610F\u56FE\u5C31\u7ACB\u523B\u5EFA\u5355\u3002\n\n\u5DE5\u5355\u5224\u5B9A\u7EC6\u5219\uFF08\u5F00\u65B0\u5355\u8FD8\u662F\u590D\u7528\uFF09\uFF1A\n1. \u5F3A\u5236\u89C4\u5219\uFF1A\u7528\u6237\u660E\u786E\u4EA4\u4EE3\u4E00\u4E2A\u65B0\u7684\u3001\u9700\u8981\u4EA4\u4ED8\u7ED3\u679C\u7684\u4EFB\u52A1\u65F6\uFF0C\u5FC5\u987B\u5148 ticket_create \u5EFA\u5355\uFF0C\u518D dispatch({ ticketID }) \u6D3E\u6D3B\u3002\u6CA1\u6709\u8C41\u514D\uFF1Adispatch \u7F3A ticketID\u3001\u5355\u4E0D\u5B58\u5728/\u5DF2\u5173\u95ED\u3001thread \u4E0D\u7B26\u3001\u6216\u8BE5\u5355\u5DF2\u6709\u8FD0\u884C\u4E2D Worker\uFF0C\u5DE5\u5177\u4F1A\u76F4\u63A5\u62A5\u9519\uFF0C\u6D3E\u6D3B\u4E0D\u4F1A\u53D1\u751F\u3002\n2. \u88AB\u62D2\u540E\u7684\u8865\u6551\uFF1A\u7F3A\u5355\u6216\u5355\u5DF2\u5173 \u2192 \u5148\u8865\u5EFA\u5355\u518D\u91CD\u6D3E\uFF1Bthread \u4E0D\u7B26 \u2192 \u7528\u8BE5\u5355\u81EA\u5DF1\u7684 threadID \u91CD\u6D3E\u6216\u4E3A\u8BE5 thread \u5EFA\u65B0\u5355\uFF1B\u5DF2\u6709\u8FD0\u884C\u4E2D Worker \u2192 \u5148\u7B49\u5B83\u7ED3\u675F\u6216 abort_session \u7EC8\u6B62\uFF08\u539F\u5355\u4F1A\u7ED3\u4E3A failed\uFF09\uFF0C\u518D ticket_create \u65B0\u5355\u91CD\u6D3E\u3002\u7EDD\u4E0D\u5141\u8BB8\u7ED5\u8FC7\u5DE5\u5355\u786C\u6D3E\u3002\n3. ticket_create \u5FC5\u987B\u5199\u6E05 objective\uFF08\u8981\u505A\u4EC0\u4E48\uFF09\u4E0E acceptance\uFF08\u600E\u4E48\u7B97\u5B8C\u6210\uFF0C1-10 \u6761\uFF09\uFF0C\u5E76\u5E26 directory\uFF08\u7EDD\u5BF9\u8DEF\u5F84\uFF09\uFF1Bacceptance \u60F3\u4E0D\u51FA\u6765\u5C31\u5148\u95EE\u7528\u6237\uFF0C\u7EDD\u4E0D\u5EFA\u7A7A\u5355\u6216\u5360\u4F4D\u5355\u3002\n4. \u540C\u4E00 thread \u91CD\u590D\u4EA4\u4EE3\u540C\u4E00\u4E2A\u4EFB\u52A1\u65F6\u76F4\u63A5\u5EFA\u5355\uFF0C\u5DE5\u5177\u4F1A\u6309 dedupeKey \u590D\u7528\u5DF2\u6709 open/in_progress \u5355\uFF1A\u8FD4\u56DE reused=true \u5C31\u6CBF\u7528\u8BE5 ticketID\uFF0C\u4E0D\u8981\u53E6\u5F00\u65B0\u5355\u3002\n5. \u540C\u4E00\u4E2A\u4EFB\u52A1\u62C6\u6210\u591A\u4E2A Worker \u65F6\uFF0C\u6BCF\u5355\u4E00\u4E2A ticketID\uFF1B\u7236\u4EFB\u52A1\u7528 parentTicketID \u5173\u8054\u3002\n6. \u63A8\u8FDB\u72B6\u6001\u7528 ticket_update\uFF08open \u2192 in_progress \u2192 blocked/done/failed/cancelled/superseded\uFF09\uFF0C\u4E0D\u8981\u8DF3\u6B65\uFF1BWorker \u7ED3\u675F\u4F1A\u81EA\u52A8\u7ED3\u5355\uFF0C\u9700\u8981\u4E3B\u52A8\u653E\u5F03\u6216\u6539\u5224\u65F6\u624D\u7528 ticket_close(done|failed|cancelled, summary)\u3002\n7. \u540C\u4E00 thread \u7684\u8FFD\u95EE\u82E5\u5C5E\u4E8E\u5DF2\u5F00\u5355\u4EFB\u52A1\uFF0C\u76F4\u63A5\u5728\u8BE5 thread \u7EE7\u7EED\uFF0C\u4E0D\u8981\u53E6\u5F00\u65B0\u5355\uFF1B\u53EA\u6709\u786E\u5B9E\u662F\u65B0\u4EFB\u52A1\u624D\u5EFA\u65B0\u5355\u3002\n8. \u72B6\u6001\u884C\u7ED9\u51FA\u5DE5\u5355\u8BA1\u6570\u4E0E\u5F53\u524D thread \u8FDB\u884C\u4E2D\u7684 ticketID+\u6807\u9898\uFF1B\u8981\u5B8C\u6574\u5217\u8868\u6216\u9A8C\u6536\u6807\u51C6\u65F6\u7528 ticket_list \u6216 /ticket <id>\uFF0C\u4E0D\u8981\u731C\u3002";
@@ -1,7 +1,8 @@
1
1
  export const ROUTER_PROMPT = `你是 Superagent Router,只负责与用户交互和任务分派,不执行具体工作。
2
2
 
3
3
  - 每个独立任务调用 dispatch 工具派给 Worker,可并行多次调用;dispatch 强制带 ticketID:先 ticket_create 建单,再派活(见下)。
4
- - 你只有 dispatch、ticket_create、ticket_list、ticket_update、ticket_close、status、thread_detail、worker_detail、report_read、abort_session 十个工具;不要尝试读文件、跑命令、改代码。
4
+ - 你只有 dispatch、ticket_create、ticket_list、ticket_update、ticket_close、status、thread_detail、worker_detail、report_read、abort_session 和 sleep 十一个工具;不要尝试读文件、跑命令、改代码。
5
+ - Goal 模式下 dispatch 后可调用 sleep(默认 30 秒,范围 1..600)暂停,给 Worker 留出执行时间;不要空转轮询 Worker。
5
6
  - Worker 完成后会收到 report;汇总后统一回复用户,不要转发原始日志。事件里的「全文长度 N,可用 report_read 读取」指向完整全文,报告被截断时用 report_read 按 offset 翻页读完,不要凭摘要猜结论。
6
7
  - 用户在同一 thread 追问时,该事件会直投负责该 thread 的 Worker(运行中直接收,已结束会被唤起继续),除非用户明确另起新任务;新 thread 才需要 dispatch 建 Worker。
7
8
  - 需要详情时调 status 查看各 Worker 的 thread 归属与近况。
@@ -55,15 +55,15 @@ export declare const Definition: {
55
55
  tickets: {
56
56
  input: import("effect/StandardSchema").StandardSchemaV1<{
57
57
  readonly threadID?: string | undefined;
58
- readonly status?: string | undefined;
58
+ readonly status?: "blocked" | "cancelled" | "done" | "failed" | "in_progress" | "open" | "superseded" | undefined;
59
59
  readonly limit?: number | undefined;
60
60
  }, {
61
61
  readonly threadID?: string | undefined;
62
- readonly status?: string | undefined;
62
+ readonly status?: "blocked" | "cancelled" | "done" | "failed" | "in_progress" | "open" | "superseded" | undefined;
63
63
  readonly limit?: number | undefined;
64
64
  }> & Schema.Struct<{
65
65
  readonly threadID: Schema.optionalKey<Schema.String>;
66
- readonly status: Schema.optionalKey<Schema.String>;
66
+ readonly status: Schema.optionalKey<Schema.Literals<readonly ["open", "in_progress", "blocked", "done", "failed", "cancelled", "superseded"]>>;
67
67
  readonly limit: Schema.optionalKey<Schema.Number>;
68
68
  }>;
69
69
  output: import("effect/StandardSchema").StandardSchemaV1<{
@@ -73,6 +73,15 @@ export declare const Definition: {
73
73
  }> & Schema.Struct<{
74
74
  readonly text: Schema.String;
75
75
  }>;
76
+ errors: {
77
+ "tickets.invalid_status": import("effect/StandardSchema").StandardSchemaV1<{
78
+ readonly status: string;
79
+ }, {
80
+ readonly status: string;
81
+ }> & Schema.Struct<{
82
+ readonly status: Schema.String;
83
+ }>;
84
+ };
76
85
  };
77
86
  ticket: {
78
87
  input: import("effect/StandardSchema").StandardSchemaV1<{
@@ -1,5 +1,9 @@
1
1
  import { Rpc } from "@opencode/plugin/effect";
2
2
  import { Schema } from "effect";
3
+ // `ticket-view` carries the TUI-safe copy of the state list (it must not import
4
+ // the KV-backed store); `test/ticket-view.test.ts` keeps it in step with
5
+ // `src/ticket.ts`, so reusing it here keeps the RPC enum from drifting.
6
+ import { TICKET_STATES } from "./ticket-view.js";
3
7
  /**
4
8
  * Server-side RPC contract for the Super-agent P0 prototype.
5
9
  *
@@ -32,10 +36,13 @@ export const Definition = Rpc.define({
32
36
  tickets: {
33
37
  input: Schema.toStandardSchemaV1(Schema.Struct({
34
38
  threadID: Schema.optionalKey(Schema.String),
35
- status: Schema.optionalKey(Schema.String),
39
+ status: Schema.optionalKey(Schema.Literals(TICKET_STATES)),
36
40
  limit: Schema.optionalKey(Schema.Number),
37
41
  })),
38
42
  output: Schema.toStandardSchemaV1(Schema.Struct({ text: Schema.String })),
43
+ errors: {
44
+ "tickets.invalid_status": Schema.toStandardSchemaV1(Schema.Struct({ status: Schema.String })),
45
+ },
39
46
  },
40
47
  ticket: {
41
48
  input: Schema.toStandardSchemaV1(Schema.Struct({ ticketID: Schema.String })),
@@ -54,6 +54,12 @@ export declare function buildReport(sessionID: string, text: string, capturedAt?
54
54
  export declare function reportSummary(record: ReportRecord): string;
55
55
  /** One router-facing line that names the full-text handle. */
56
56
  export declare function reportNotice(record: ReportRecord): string;
57
+ /**
58
+ * Last non-empty assistant text in a session-context snapshot. Shared by the
59
+ * live watcher and startup hydration so a crash-recovered worker gets the
60
+ * same report extraction as one that ended while the plugin was running.
61
+ */
62
+ export declare function lastAssistantText(messages: ReadonlyArray<unknown>): string | undefined;
57
63
  /** Page through a stored report; the caller validates offset against `stored`. */
58
64
  export declare function sliceReport(record: ReportRecord, offset: number, limit: number): ReportSlice;
59
65
  export declare function encodeReport(record: ReportRecord): Schema.Json;
@@ -44,6 +44,35 @@ export function reportNotice(record) {
44
44
  const clip = record.truncated ? `,超出 ${REPORT_TEXT_LIMIT} 字上限仅存前 ${record.text.length} 字` : "";
45
45
  return `全文长度 ${record.length}${clip},可用 report_read 读取(sessionID: ${record.sessionID})`;
46
46
  }
47
+ /**
48
+ * Last non-empty assistant text in a session-context snapshot. Shared by the
49
+ * live watcher and startup hydration so a crash-recovered worker gets the
50
+ * same report extraction as one that ended while the plugin was running.
51
+ */
52
+ export function lastAssistantText(messages) {
53
+ for (let index = messages.length - 1; index >= 0; index--) {
54
+ const message = messages[index];
55
+ if (typeof message !== "object" || message === null)
56
+ continue;
57
+ const typed = message;
58
+ if (typed.type !== "assistant")
59
+ continue;
60
+ if (!Array.isArray(typed.content))
61
+ continue;
62
+ const text = typed.content
63
+ .flatMap((part) => {
64
+ if (typeof part !== "object" || part === null)
65
+ return [];
66
+ const candidate = part;
67
+ return candidate.type === "text" && typeof candidate.text === "string" ? [candidate.text] : [];
68
+ })
69
+ .join("\n")
70
+ .trim();
71
+ if (text !== "")
72
+ return text;
73
+ }
74
+ return undefined;
75
+ }
47
76
  /** Page through a stored report; the caller validates offset against `stored`. */
48
77
  export function sliceReport(record, offset, limit) {
49
78
  const stored = record.text.length;
@@ -22,9 +22,8 @@ export declare const hydratePending: (ctx: Plugin.Context) => Effect.Effect<void
22
22
  */
23
23
  export declare const ensureRouter: (ctx: Plugin.Context) => Effect.Effect<string, unknown, never>;
24
24
  /**
25
- * Merge queued triggers into one synthetic prompt for the router. Items
26
- * leave the queue (memory and KV) only after the injection succeeds; on
27
- * failure they stay queued for the next attempt.
25
+ * Serialised entry point for every flush caller (idle hook, RPC, tests).
26
+ * Concurrent calls wait on `flushLock`, so one batch is delivered once.
28
27
  */
29
28
  export declare const flushPending: (ctx: Plugin.Context) => Effect.Effect<void, unknown, never>;
30
29
  /**
@@ -1,5 +1,5 @@
1
1
  import { Agent } from "@opencode/plugin/effect";
2
- import { Effect, Option } from "effect";
2
+ import { Effect, Option, Semaphore } from "effect";
3
3
  import { ROUTER_AGENT } from "./agents.js";
4
4
  import { threadDirHint } from "./dirs.js";
5
5
  import { formatTrigger } from "./format.js";
@@ -8,6 +8,13 @@ import { ROUTER_KEY, store, toSessionID } from "./state.js";
8
8
  import { noteRouterTrigger } from "./ticket.js";
9
9
  import { ROUTER_PERMISSIONS } from "./tools.js";
10
10
  const MAX_FLUSH = 20;
11
+ /**
12
+ * Serialise flushes. A second caller waits for the in-flight flush to finish,
13
+ * then observes the drained queue and only flushes events enqueued meanwhile,
14
+ * so the same batch is never injected twice. Effect releases the permit on
15
+ * success, failure and interruption.
16
+ */
17
+ const flushLock = Semaphore.makeUnsafe(1);
11
18
  /** KV prefix for crash-safe pending triggers. Owned by the router; `state.ts` stays untouched. */
12
19
  export const PENDING_PREFIX = "pending:";
13
20
  const pendingKey = (id) => `${PENDING_PREFIX}${id}`;
@@ -161,11 +168,15 @@ export const ensureRouter = Effect.fn("superagent.ensureRouter")(function* (ctx)
161
168
  * leave the queue (memory and KV) only after the injection succeeds; on
162
169
  * failure they stay queued for the next attempt.
163
170
  */
164
- export const flushPending = Effect.fn("superagent.flushPending")(function* (ctx) {
171
+ const flushPendingUnlocked = Effect.fn("superagent.flushPendingUnlocked")(function* (ctx) {
165
172
  if (store.pending.length === 0)
166
173
  return;
167
174
  const routerID = yield* ensureRouter(ctx);
168
175
  const batch = store.pending.slice(0, MAX_FLUSH);
176
+ // Identity snapshot of this delivery. Removal below matches these ids
177
+ // instead of the queue prefix, so events enqueued while the injection is
178
+ // in flight survive.
179
+ const batchIDs = new Set(batch.map((item) => pendingID(item.trigger, item.arrivedAt)));
169
180
  const text = [
170
181
  `${batch.length} new event(s) for the router:`,
171
182
  ...batch.map((item) => {
@@ -183,7 +194,14 @@ export const flushPending = Effect.fn("superagent.flushPending")(function* (ctx)
183
194
  });
184
195
  return;
185
196
  }
186
- store.pending.splice(0, batch.length);
197
+ // Remove by delivered id, never by position: the queue may have grown at
198
+ // the tail (or been trimmed elsewhere) while `synthetic` was awaited.
199
+ for (let index = store.pending.length - 1; index >= 0; index -= 1) {
200
+ const item = store.pending[index];
201
+ if (item !== undefined && batchIDs.has(pendingID(item.trigger, item.arrivedAt))) {
202
+ store.pending.splice(index, 1);
203
+ }
204
+ }
187
205
  // Remember what the Router was just told: `ticket_create` copies this
188
206
  // trigger into the ticket's authorization (id + quote).
189
207
  const newest = batch[batch.length - 1];
@@ -193,6 +211,13 @@ export const flushPending = Effect.fn("superagent.flushPending")(function* (ctx)
193
211
  yield* ctx.storage.remove(keyOf(item)).pipe(Effect.catch(() => Effect.void));
194
212
  }
195
213
  });
214
+ /**
215
+ * Serialised entry point for every flush caller (idle hook, RPC, tests).
216
+ * Concurrent calls wait on `flushLock`, so one batch is delivered once.
217
+ */
218
+ export const flushPending = Effect.fn("superagent.flushPending")(function* (ctx) {
219
+ yield* flushLock.withPermit(flushPendingUnlocked(ctx));
220
+ });
196
221
  /**
197
222
  * Router idle hook: when the router goes idle with nothing pending in the
198
223
  * background, flush queued triggers into the same execution. An empty queue
@@ -0,0 +1,35 @@
1
+ import type { Plugin } from "@opencode/plugin/effect";
2
+ import { Tool } from "@opencode/schema/tool";
3
+ import { Effect, Schema } from "effect";
4
+ /**
5
+ * Router-only `sleep` tool: a bounded, real pause after `dispatch` so the
6
+ * Worker gets execution time before the Router decides to act again. It is a
7
+ * timer, never a busy loop, so the Router session (and every other fiber in
8
+ * the process) stays responsive while it waits.
9
+ */
10
+ export declare const SLEEP_DEFAULT_SECONDS = 30;
11
+ export declare const SLEEP_MIN_SECONDS = 1;
12
+ export declare const SLEEP_MAX_SECONDS = 600;
13
+ export declare const SleepInput: Schema.Struct<{
14
+ readonly seconds: Schema.optionalKey<Schema.Number>;
15
+ }>;
16
+ export declare const SleepOutput: Schema.Struct<{
17
+ readonly slept: Schema.Number;
18
+ readonly text: Schema.String;
19
+ }>;
20
+ interface SleepInput {
21
+ readonly seconds?: number;
22
+ }
23
+ /** Performs the wait; tests inject a no-op so the default 30s never runs. */
24
+ export type Sleeper = (seconds: number) => Effect.Effect<void>;
25
+ /** The production sleeper: a platform timer, not a spin loop. */
26
+ export declare const effectSleeper: Sleeper;
27
+ export declare const runSleep: (input: SleepInput, caller: string, sleeper?: Sleeper | undefined) => Effect.Effect<{
28
+ output: {
29
+ slept: number;
30
+ text: string;
31
+ };
32
+ content: string;
33
+ }, Tool.Error, never>;
34
+ export declare const registerSleep: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
35
+ export {};
@@ -0,0 +1,58 @@
1
+ import { Tool } from "@opencode/schema/tool";
2
+ import { Duration, Effect, Schema } from "effect";
3
+ import { requireRouter } from "./guard.js";
4
+ import { specFor, toolPermission } from "./tools.js";
5
+ /**
6
+ * Router-only `sleep` tool: a bounded, real pause after `dispatch` so the
7
+ * Worker gets execution time before the Router decides to act again. It is a
8
+ * timer, never a busy loop, so the Router session (and every other fiber in
9
+ * the process) stays responsive while it waits.
10
+ */
11
+ export const SLEEP_DEFAULT_SECONDS = 30;
12
+ export const SLEEP_MIN_SECONDS = 1;
13
+ export const SLEEP_MAX_SECONDS = 600;
14
+ export const SleepInput = Schema.Struct({
15
+ seconds: Schema.optionalKey(Schema.Number),
16
+ });
17
+ export const SleepOutput = Schema.Struct({
18
+ slept: Schema.Number,
19
+ text: Schema.String,
20
+ });
21
+ /** The production sleeper: a platform timer, not a spin loop. */
22
+ export const effectSleeper = (seconds) => Effect.sleep(Duration.seconds(seconds));
23
+ export const runSleep = Effect.fn("superagent.sleep")(function* (input, caller, sleeper = effectSleeper) {
24
+ yield* requireRouter(caller, "sleep");
25
+ const seconds = input.seconds ?? SLEEP_DEFAULT_SECONDS;
26
+ // The registered schema already rejects non-numbers at the tool boundary;
27
+ // this re-check keeps direct/RPC-style callers from bypassing validation.
28
+ if (typeof seconds !== "number" || !Number.isFinite(seconds)) {
29
+ return yield* new Tool.Error({
30
+ message: `sleep seconds must be a number between ${SLEEP_MIN_SECONDS} and ${SLEEP_MAX_SECONDS} (got ${String(input.seconds)})`,
31
+ });
32
+ }
33
+ if (!Number.isInteger(seconds)) {
34
+ return yield* new Tool.Error({
35
+ message: `sleep seconds must be an integer between ${SLEEP_MIN_SECONDS} and ${SLEEP_MAX_SECONDS} (got ${seconds})`,
36
+ });
37
+ }
38
+ if (seconds < SLEEP_MIN_SECONDS || seconds > SLEEP_MAX_SECONDS) {
39
+ return yield* new Tool.Error({
40
+ message: `sleep seconds must be between ${SLEEP_MIN_SECONDS} and ${SLEEP_MAX_SECONDS} (got ${seconds})`,
41
+ });
42
+ }
43
+ yield* sleeper(seconds);
44
+ const text = `slept ${seconds}s`;
45
+ return { output: { slept: seconds, text }, content: text };
46
+ });
47
+ export const registerSleep = Effect.fn("superagent.registerSleep")(function* (ctx) {
48
+ yield* ctx.tool.transform((editor) => {
49
+ editor.add({
50
+ name: "sleep",
51
+ description: specFor("sleep").description,
52
+ input: SleepInput,
53
+ output: SleepOutput,
54
+ options: { permission: toolPermission(specFor("sleep")) },
55
+ execute: (input, context) => runSleep(input, context.sessionID),
56
+ });
57
+ });
58
+ });
@@ -99,6 +99,8 @@ export declare const threadKey: (threadID: string) => string;
99
99
  export declare const triggerKey: (id: string) => string;
100
100
  /** Brands are compile-time only, so a validated session id string converts freely. */
101
101
  export declare const toSessionID: (id: string) => Session.ID;
102
+ /** A Session is running while `time.busy` is newer than the last completed execution's `time.idle`. */
103
+ export declare function isRunning(info: Session.Info): boolean;
102
104
  export declare function decodeWorker(value: Schema.Json | undefined): WorkerRecord | undefined;
103
105
  export declare function decodeThread(value: Schema.Json | undefined): ThreadRecord | undefined;
104
106
  export declare function decodeTrigger(value: Schema.Json | undefined): TriggerRecord | undefined;
package/dist/src/state.js CHANGED
@@ -1,4 +1,6 @@
1
- import { Effect } from "effect";
1
+ import { DateTime, Effect } from "effect";
2
+ import { buildReport, lastAssistantText, reportSummary, saveReport } from "./report.js";
3
+ import { closeTicketsForWorker } from "./ticket.js";
2
4
  const ROUTES = ["router", "worker"];
3
5
  /** Host context for fire-and-forget KV writes from synchronous interception points. Set during hydrate. */
4
6
  let persistHost;
@@ -120,6 +122,14 @@ export const threadKey = (threadID) => `${THREAD_PREFIX}${threadID}`;
120
122
  export const triggerKey = (id) => `${TRIGGER_PREFIX}${id}`;
121
123
  /** Brands are compile-time only, so a validated session id string converts freely. */
122
124
  export const toSessionID = (id) => id;
125
+ /** A Session is running while `time.busy` is newer than the last completed execution's `time.idle`. */
126
+ export function isRunning(info) {
127
+ const busy = info.time.busy;
128
+ if (busy === undefined)
129
+ return false;
130
+ const idle = info.time.idle;
131
+ return idle === undefined || DateTime.toEpochMillis(busy) > DateTime.toEpochMillis(idle);
132
+ }
123
133
  const STATES = ["running", "idle", "failed", "done", "aborted"];
124
134
  function isRecord(value) {
125
135
  return typeof value === "object" && value !== null;
@@ -260,6 +270,43 @@ const removeWorker = Effect.fn("superagent.removeWorker")(function* (ctx, sessio
260
270
  .remove(workerKey(sessionID))
261
271
  .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to remove worker", { cause })));
262
272
  });
273
+ /**
274
+ * Reconcile one hydrated `running` record with the live session. A session
275
+ * that is still executing keeps `running`; one that finished while the plugin
276
+ * was down is backfilled the same way the watcher would have handled it:
277
+ * capture the last assistant report, persist the terminal/interrupted state,
278
+ * and close its ticket for `done`/`failed`.
279
+ */
280
+ const reconcileRunningWorker = Effect.fn("superagent.reconcileRunningWorker")(function* (ctx, record, info) {
281
+ if (isRunning(info))
282
+ return;
283
+ const state = info.outcome === "succeeded" ? "done" : info.outcome === "failed" ? "failed" : "idle";
284
+ record.state = state;
285
+ const sessions = ctx.session;
286
+ const messages = typeof sessions.context === "function"
287
+ ? yield* sessions
288
+ .context({ sessionID: toSessionID(record.sessionID) })
289
+ .pipe(Effect.catch(() => Effect.succeed([])))
290
+ : [];
291
+ const report = lastAssistantText(messages);
292
+ if (report !== undefined) {
293
+ const captured = buildReport(record.sessionID, report);
294
+ yield* saveReport(ctx, captured);
295
+ record.lastReport = reportSummary(captured);
296
+ }
297
+ yield* saveWorker(ctx, record);
298
+ if (state === "done" || state === "failed") {
299
+ yield* closeTicketsForWorker(ctx, {
300
+ sessionID: record.sessionID,
301
+ outcome: state,
302
+ summary: record.lastReport ?? report ?? `${state} (worker ${record.sessionID})`,
303
+ }).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to close ticket for rehydrated worker", {
304
+ sessionID: record.sessionID,
305
+ cause,
306
+ })));
307
+ }
308
+ yield* Effect.logInfo("superagent rehydrated ended worker", { sessionID: record.sessionID, state });
309
+ });
263
310
  /**
264
311
  * Load KV state into the in-memory maps. Storage is the unique source of
265
312
  * truth; every loaded session is validated with `session.get` and stale
@@ -296,6 +343,8 @@ export const hydrate = Effect.fn("superagent.hydrate")(function* (ctx) {
296
343
  continue;
297
344
  }
298
345
  store.workers.set(record.sessionID, record);
346
+ if (record.state === "running")
347
+ yield* reconcileRunningWorker(ctx, record, info);
299
348
  }
300
349
  after = page.next;
301
350
  } while (after !== undefined);
@@ -1,7 +1,7 @@
1
1
  import type { Plugin } from "@opencode/plugin/effect";
2
2
  import { Tool } from "@opencode/schema/tool";
3
3
  import { Effect, type Schema } from "effect";
4
- import { type TriggerInput } from "./state.js";
4
+ import { type TriggerInput, type WorkerState } from "./state.js";
5
5
  /**
6
6
  * Ticket (work order) store for the Super-agent P0.
7
7
  *
@@ -142,6 +142,13 @@ export declare function ticketStats(): TicketStats;
142
142
  export declare function activeTicketSummary(threadID: string | undefined, limit?: number): string;
143
143
  /** Active ticket for a worker session, used when a worker ends. */
144
144
  export declare function activeTicketForWorker(sessionID: string): Ticket | undefined;
145
+ /**
146
+ * Worker states after which the ticket may be dispatched again. `idle` is not
147
+ * here on purpose: an idle session can still be resumed, so a re-dispatch
148
+ * would race a live worker.
149
+ */
150
+ export declare const TERMINAL_WORKER_STATES: ReadonlyArray<WorkerState>;
151
+ export declare function isWorkerTerminal(state: WorkerState): boolean;
145
152
  export declare function validateCreateInput(input: {
146
153
  readonly threadID?: string;
147
154
  readonly title?: string;
@@ -209,7 +216,7 @@ export declare function listTickets(input?: ListTicketsInput): Array<Ticket>;
209
216
  * Gate every dispatch must pass: a dispatch always names a ticket, and that
210
217
  * ticket must be workable right now. The rules are enforced here (not in the
211
218
  * prompt) so a Router cannot dispatch without a ticket, onto a closed ticket,
212
- * across threads, or onto a ticket that already has a running worker.
219
+ * across threads, or onto a ticket that already has a live (non-terminal) worker.
213
220
  */
214
221
  export declare const requireDispatchTicket: (ticketID: string | undefined, threadID: string | undefined) => Effect.Effect<Ticket, Tool.Error, never>;
215
222
  export interface LinkTicketInput {