opencode-superagent 0.1.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.
@@ -1,27 +1,8 @@
1
1
  import type { Plugin } from "@opencode/plugin/effect";
2
2
  import type { Session } from "@opencode/schema/session";
3
3
  import { Effect, type Schema } from "effect";
4
- export type WorkerState = "running" | "idle" | "failed" | "done" | "aborted";
5
- export interface WorkerRecord {
6
- readonly sessionID: string;
7
- readonly title?: string;
8
- /**
9
- * Primary thread this worker was dispatched for. Legacy records decode to
10
- * `""` and `hydrate` backfills it from `thread.workers`, so every live record
11
- * carries a forward link to its thread without losing the multi-thread
12
- * `threads` list.
13
- */
14
- threadID: string;
15
- /** Trigger that authorized the dispatch, when the record knows it. */
16
- triggerID?: string;
17
- readonly threads: Array<string>;
18
- state: WorkerState;
19
- readonly createdAt: number;
20
- /** Last persisted write. Refreshed by `saveWorker`, so an aborted record keeps a traceable timestamp. */
21
- updatedAt?: number;
22
- lastReport?: string;
23
- directory?: string;
24
- }
4
+ import { type WorkerRecord } from "./worker-state.js";
5
+ export type { WorkerRecord, WorkerState } from "./worker-state.js";
25
6
  export interface ThreadRecord {
26
7
  readonly threadID: string;
27
8
  readonly workers: Array<string>;
@@ -99,6 +80,8 @@ export declare const threadKey: (threadID: string) => string;
99
80
  export declare const triggerKey: (id: string) => string;
100
81
  /** Brands are compile-time only, so a validated session id string converts freely. */
101
82
  export declare const toSessionID: (id: string) => Session.ID;
83
+ /** A Session is running while `time.busy` is newer than the last completed execution's `time.idle`. */
84
+ export declare function isRunning(info: Session.Info): boolean;
102
85
  export declare function decodeWorker(value: Schema.Json | undefined): WorkerRecord | undefined;
103
86
  export declare function decodeThread(value: Schema.Json | undefined): ThreadRecord | undefined;
104
87
  export declare function decodeTrigger(value: Schema.Json | undefined): TriggerRecord | undefined;
package/dist/src/state.js CHANGED
@@ -1,4 +1,8 @@
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";
4
+ import { closeOutcomeForWorkerState } from "./ticket-state.js";
5
+ import { WORKER_STATES, workers } from "./worker-state.js";
2
6
  const ROUTES = ["router", "worker"];
3
7
  /** Host context for fire-and-forget KV writes from synchronous interception points. Set during hydrate. */
4
8
  let persistHost;
@@ -104,7 +108,7 @@ export function upsertDirectTrigger(input, sessionID) {
104
108
  export const store = {
105
109
  routerID: undefined,
106
110
  routerBusy: false,
107
- workers: new Map(),
111
+ workers,
108
112
  threads: new Map(),
109
113
  pending: new PendingQueue(),
110
114
  triggers: new Map(),
@@ -120,7 +124,14 @@ export const threadKey = (threadID) => `${THREAD_PREFIX}${threadID}`;
120
124
  export const triggerKey = (id) => `${TRIGGER_PREFIX}${id}`;
121
125
  /** Brands are compile-time only, so a validated session id string converts freely. */
122
126
  export const toSessionID = (id) => id;
123
- const STATES = ["running", "idle", "failed", "done", "aborted"];
127
+ /** A Session is running while `time.busy` is newer than the last completed execution's `time.idle`. */
128
+ export function isRunning(info) {
129
+ const busy = info.time.busy;
130
+ if (busy === undefined)
131
+ return false;
132
+ const idle = info.time.idle;
133
+ return idle === undefined || DateTime.toEpochMillis(busy) > DateTime.toEpochMillis(idle);
134
+ }
124
135
  function isRecord(value) {
125
136
  return typeof value === "object" && value !== null;
126
137
  }
@@ -144,7 +155,7 @@ export function decodeWorker(value) {
144
155
  return undefined;
145
156
  if (typeof value["sessionID"] !== "string")
146
157
  return undefined;
147
- if (typeof value["state"] !== "string" || !STATES.includes(value["state"]))
158
+ if (typeof value["state"] !== "string" || !WORKER_STATES.includes(value["state"]))
148
159
  return undefined;
149
160
  const threads = stringArray(value["threads"]);
150
161
  if (threads === undefined)
@@ -260,6 +271,44 @@ const removeWorker = Effect.fn("superagent.removeWorker")(function* (ctx, sessio
260
271
  .remove(workerKey(sessionID))
261
272
  .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to remove worker", { cause })));
262
273
  });
274
+ /**
275
+ * Reconcile one hydrated `running` record with the live session. A session
276
+ * that is still executing keeps `running`; one that finished while the plugin
277
+ * was down is backfilled the same way the watcher would have handled it:
278
+ * capture the last assistant report, persist the terminal/interrupted state,
279
+ * and close its ticket for `done`/`failed`.
280
+ */
281
+ const reconcileRunningWorker = Effect.fn("superagent.reconcileRunningWorker")(function* (ctx, record, info) {
282
+ if (isRunning(info))
283
+ return;
284
+ const state = info.outcome === "succeeded" ? "done" : info.outcome === "failed" ? "failed" : "idle";
285
+ record.state = state;
286
+ const sessions = ctx.session;
287
+ const messages = typeof sessions.context === "function"
288
+ ? yield* sessions
289
+ .context({ sessionID: toSessionID(record.sessionID) })
290
+ .pipe(Effect.catch(() => Effect.succeed([])))
291
+ : [];
292
+ const report = lastAssistantText(messages);
293
+ if (report !== undefined) {
294
+ const captured = buildReport(record.sessionID, report);
295
+ yield* saveReport(ctx, captured);
296
+ record.lastReport = reportSummary(captured);
297
+ }
298
+ yield* saveWorker(ctx, record);
299
+ const outcome = closeOutcomeForWorkerState(state);
300
+ if (outcome !== undefined) {
301
+ yield* closeTicketsForWorker(ctx, {
302
+ sessionID: record.sessionID,
303
+ outcome,
304
+ summary: record.lastReport ?? report ?? `${outcome} (worker ${record.sessionID})`,
305
+ }).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to close ticket for rehydrated worker", {
306
+ sessionID: record.sessionID,
307
+ cause,
308
+ })));
309
+ }
310
+ yield* Effect.logInfo("superagent rehydrated ended worker", { sessionID: record.sessionID, state });
311
+ });
263
312
  /**
264
313
  * Load KV state into the in-memory maps. Storage is the unique source of
265
314
  * truth; every loaded session is validated with `session.get` and stale
@@ -296,6 +345,8 @@ export const hydrate = Effect.fn("superagent.hydrate")(function* (ctx) {
296
345
  continue;
297
346
  }
298
347
  store.workers.set(record.sessionID, record);
348
+ if (record.state === "running")
349
+ yield* reconcileRunningWorker(ctx, record, info);
299
350
  }
300
351
  after = page.next;
301
352
  } while (after !== undefined);
@@ -0,0 +1,97 @@
1
+ import type { WorkerState } from "./worker-state.js";
2
+ /**
3
+ * Pure ticket domain model: records, the state machine, and the selectors
4
+ * shared by the ticket store, the dispatcher and the worker lifecycle.
5
+ *
6
+ * Nothing here touches the KV store, Effect or the worker registry, so this
7
+ * module can be imported from any layer without recreating the state ↔ ticket
8
+ * import cycle. `ticket.ts` keeps the effectful store and re-exports these
9
+ * names, so its public API is unchanged.
10
+ */
11
+ export type TicketState = "open" | "in_progress" | "blocked" | "done" | "failed" | "cancelled" | "superseded";
12
+ export type TicketPriority = "p0" | "p1" | "p2";
13
+ export type TicketCloseOutcome = "done" | "failed" | "cancelled";
14
+ export interface TicketRequester {
15
+ readonly source: string;
16
+ readonly threadID: string;
17
+ readonly author?: string;
18
+ }
19
+ /** Which trigger authorized the ticket: the id plus a short quote of what the user said. */
20
+ export interface TicketAuthorization {
21
+ readonly triggerID: string;
22
+ readonly quote: string;
23
+ readonly at: number;
24
+ }
25
+ export interface TicketAttempt {
26
+ readonly at: number;
27
+ readonly sessionID?: string;
28
+ readonly note?: string;
29
+ }
30
+ export interface TicketHistoryEntry {
31
+ readonly at: number;
32
+ readonly kind: "created" | "state" | "note" | "dispatch" | "closed";
33
+ readonly from?: TicketState;
34
+ readonly to?: TicketState;
35
+ readonly note?: string;
36
+ }
37
+ export interface Ticket {
38
+ readonly id: string;
39
+ state: TicketState;
40
+ readonly title: string;
41
+ readonly objective: string;
42
+ readonly acceptance: ReadonlyArray<string>;
43
+ readonly requester: TicketRequester;
44
+ readonly authorization: TicketAuthorization;
45
+ readonly threadID: string;
46
+ workerSessionID?: string;
47
+ readonly attempts: Array<TicketAttempt>;
48
+ readonly priority: TicketPriority;
49
+ directory?: string;
50
+ readonly dedupeKey: string;
51
+ readonly parentTicketID?: string;
52
+ readonly tags: Array<string>;
53
+ readonly createdAt: number;
54
+ updatedAt: number;
55
+ closedAt?: number;
56
+ closeReason?: string;
57
+ readonly history: Array<TicketHistoryEntry>;
58
+ }
59
+ export declare const PRIORITIES: ReadonlyArray<TicketPriority>;
60
+ export declare const TICKET_STATES: ReadonlyArray<TicketState>;
61
+ /** A ticket can be dispatched while it is one of these. */
62
+ export declare const ACTIVE_STATES: ReadonlyArray<TicketState>;
63
+ export declare function isTerminalState(state: TicketState): boolean;
64
+ export declare function allowedTransitions(state: TicketState): ReadonlyArray<TicketState>;
65
+ /** Same-state moves are no-ops, not transitions. */
66
+ export declare function canTransition(from: TicketState, to: TicketState): boolean;
67
+ /** Human-readable reason a move is illegal, or `undefined` when it is allowed. */
68
+ export declare function transitionError(from: TicketState, to: TicketState): string | undefined;
69
+ /** Minimal shape a worker-bound ticket needs, so the selector stays pure and generic. */
70
+ export interface WorkerBoundTicket {
71
+ readonly workerSessionID?: string;
72
+ readonly state: TicketState;
73
+ }
74
+ /**
75
+ * The `in_progress` ticket attached to a worker session, if any. Shared by the
76
+ * dispatch link and worker-end closing so both sides agree on "active ticket
77
+ * for this worker"; insertion order decides ties, as before.
78
+ */
79
+ export declare function findActiveTicketForWorker<T extends WorkerBoundTicket>(candidates: Iterable<T>, sessionID: string): T | undefined;
80
+ /**
81
+ * Ticket outcome for a worker that just ended, or `undefined` when the worker
82
+ * end closes nothing. `idle`/`aborted`/`running` leave the ticket for the abort
83
+ * path or a deliberate re-dispatch.
84
+ */
85
+ export declare function closeOutcomeForWorkerState(state: WorkerState): "done" | "failed" | undefined;
86
+ /**
87
+ * Reason recorded when a worker end closes its ticket: an explicit summary
88
+ * wins, then the caller's note, then a generated `done (worker <id>)` line.
89
+ * The store still compacts it to `SUMMARY_MAX` before persisting, exactly as
90
+ * before this helper existed.
91
+ */
92
+ export declare function workerCloseSummary(input: {
93
+ readonly sessionID: string;
94
+ readonly outcome: "done" | "failed";
95
+ readonly summary?: string | undefined;
96
+ readonly note?: string | undefined;
97
+ }): string;
@@ -0,0 +1,70 @@
1
+ export const PRIORITIES = ["p0", "p1", "p2"];
2
+ export const TICKET_STATES = [
3
+ "open",
4
+ "in_progress",
5
+ "blocked",
6
+ "done",
7
+ "failed",
8
+ "cancelled",
9
+ "superseded",
10
+ ];
11
+ /** A ticket can be dispatched while it is one of these. */
12
+ export const ACTIVE_STATES = ["open", "in_progress"];
13
+ const TRANSITIONS = {
14
+ // A ticket can close from any active state: `done`/`failed` also cover work
15
+ // finished outside the worker (or a dispatch that never started).
16
+ open: ["in_progress", "blocked", "done", "failed", "cancelled", "superseded"],
17
+ in_progress: ["blocked", "done", "failed", "cancelled", "superseded"],
18
+ blocked: ["in_progress", "done", "failed", "cancelled", "superseded"],
19
+ done: [],
20
+ failed: [],
21
+ cancelled: [],
22
+ superseded: [],
23
+ };
24
+ export function isTerminalState(state) {
25
+ return TRANSITIONS[state].length === 0;
26
+ }
27
+ export function allowedTransitions(state) {
28
+ return TRANSITIONS[state];
29
+ }
30
+ /** Same-state moves are no-ops, not transitions. */
31
+ export function canTransition(from, to) {
32
+ return from === to || TRANSITIONS[from].includes(to);
33
+ }
34
+ /** Human-readable reason a move is illegal, or `undefined` when it is allowed. */
35
+ export function transitionError(from, to) {
36
+ if (canTransition(from, to))
37
+ return undefined;
38
+ if (isTerminalState(from))
39
+ return `ticket is already closed (${from}); cannot move to ${to}`;
40
+ return `illegal transition ${from} -> ${to}; allowed: ${TRANSITIONS[from].join(", ")}`;
41
+ }
42
+ /**
43
+ * The `in_progress` ticket attached to a worker session, if any. Shared by the
44
+ * dispatch link and worker-end closing so both sides agree on "active ticket
45
+ * for this worker"; insertion order decides ties, as before.
46
+ */
47
+ export function findActiveTicketForWorker(candidates, sessionID) {
48
+ for (const ticket of candidates) {
49
+ if (ticket.workerSessionID === sessionID && ticket.state === "in_progress")
50
+ return ticket;
51
+ }
52
+ return undefined;
53
+ }
54
+ /**
55
+ * Ticket outcome for a worker that just ended, or `undefined` when the worker
56
+ * end closes nothing. `idle`/`aborted`/`running` leave the ticket for the abort
57
+ * path or a deliberate re-dispatch.
58
+ */
59
+ export function closeOutcomeForWorkerState(state) {
60
+ return state === "done" || state === "failed" ? state : undefined;
61
+ }
62
+ /**
63
+ * Reason recorded when a worker end closes its ticket: an explicit summary
64
+ * wins, then the caller's note, then a generated `done (worker <id>)` line.
65
+ * The store still compacts it to `SUMMARY_MAX` before persisting, exactly as
66
+ * before this helper existed.
67
+ */
68
+ export function workerCloseSummary(input) {
69
+ return input.summary ?? input.note ?? `${input.outcome} (worker ${input.sessionID})`;
70
+ }
@@ -1,7 +1,24 @@
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 Ticket, type TicketAuthorization, type TicketCloseOutcome, type TicketPriority, type TicketState } from "./ticket-state.js";
5
+ export type { Ticket, TicketAttempt, TicketAuthorization, TicketCloseOutcome, TicketHistoryEntry, TicketPriority, TicketRequester, TicketState, } from "./ticket-state.js";
6
+ export { ACTIVE_STATES, allowedTransitions, canTransition, isTerminalState, TICKET_STATES, transitionError, } from "./ticket-state.js";
7
+ export { isWorkerTerminal, TERMINAL_WORKER_STATES } from "./worker-state.js";
8
+ /**
9
+ * Structural copy of the queued trigger input. The ticket layer must not import
10
+ * `state.ts` (that is the cycle this module just broke), and the shape is all
11
+ * `noteRouterTrigger` needs.
12
+ */
13
+ export interface TicketTriggerNoteInput {
14
+ readonly id?: string;
15
+ readonly source: string;
16
+ readonly threadID: string;
17
+ readonly author?: string;
18
+ readonly text: string;
19
+ readonly title?: string;
20
+ readonly createdAt?: number;
21
+ }
5
22
  /**
6
23
  * Ticket (work order) store for the Super-agent P0.
7
24
  *
@@ -23,54 +40,6 @@ import { type TriggerInput } from "./state.js";
23
40
  *
24
41
  * `done`, `failed`, `cancelled` and `superseded` are terminal.
25
42
  */
26
- export type TicketState = "open" | "in_progress" | "blocked" | "done" | "failed" | "cancelled" | "superseded";
27
- export type TicketPriority = "p0" | "p1" | "p2";
28
- export type TicketCloseOutcome = "done" | "failed" | "cancelled";
29
- export interface TicketRequester {
30
- readonly source: string;
31
- readonly threadID: string;
32
- readonly author?: string;
33
- }
34
- /** Which trigger authorized the ticket: the id plus a short quote of what the user said. */
35
- export interface TicketAuthorization {
36
- readonly triggerID: string;
37
- readonly quote: string;
38
- readonly at: number;
39
- }
40
- export interface TicketAttempt {
41
- readonly at: number;
42
- readonly sessionID?: string;
43
- readonly note?: string;
44
- }
45
- export interface TicketHistoryEntry {
46
- readonly at: number;
47
- readonly kind: "created" | "state" | "note" | "dispatch" | "closed";
48
- readonly from?: TicketState;
49
- readonly to?: TicketState;
50
- readonly note?: string;
51
- }
52
- export interface Ticket {
53
- readonly id: string;
54
- state: TicketState;
55
- readonly title: string;
56
- readonly objective: string;
57
- readonly acceptance: ReadonlyArray<string>;
58
- readonly requester: TicketRequester;
59
- readonly authorization: TicketAuthorization;
60
- readonly threadID: string;
61
- workerSessionID?: string;
62
- readonly attempts: Array<TicketAttempt>;
63
- readonly priority: TicketPriority;
64
- directory?: string;
65
- readonly dedupeKey: string;
66
- readonly parentTicketID?: string;
67
- readonly tags: Array<string>;
68
- readonly createdAt: number;
69
- updatedAt: number;
70
- closedAt?: number;
71
- closeReason?: string;
72
- readonly history: Array<TicketHistoryEntry>;
73
- }
74
43
  export declare const TICKET_PREFIX = "ticket:";
75
44
  export declare const TICKET_STATUS_PREFIX = "ticketidx:status:";
76
45
  export declare const TICKET_THREAD_PREFIX = "ticketidx:thread:";
@@ -87,15 +56,6 @@ export declare const INDEX_LIMIT = 200;
87
56
  export declare const MEMORY_LIMIT = 500;
88
57
  export declare const LIST_DEFAULT = 20;
89
58
  export declare const LIST_MAX = 100;
90
- export declare const TICKET_STATES: ReadonlyArray<TicketState>;
91
- /** A ticket can be dispatched while it is one of these. */
92
- export declare const ACTIVE_STATES: ReadonlyArray<TicketState>;
93
- export declare function isTerminalState(state: TicketState): boolean;
94
- export declare function allowedTransitions(state: TicketState): ReadonlyArray<TicketState>;
95
- /** Same-state moves are no-ops, not transitions. */
96
- export declare function canTransition(from: TicketState, to: TicketState): boolean;
97
- /** Human-readable reason a move is illegal, or `undefined` when it is allowed. */
98
- export declare function transitionError(from: TicketState, to: TicketState): string | undefined;
99
59
  /** Lowercased, whitespace-collapsed objective, so wording drift does not defeat dedupe. */
100
60
  export declare function normalizeObjective(text: string): string;
101
61
  export declare function dedupeKeyFor(input: {
@@ -122,7 +82,7 @@ export interface RouterTriggerNote {
122
82
  readonly text: string;
123
83
  readonly at: number;
124
84
  }
125
- export declare function noteRouterTrigger(trigger: TriggerInput): void;
85
+ export declare function noteRouterTrigger(trigger: TicketTriggerNoteInput): void;
126
86
  export declare function currentTrigger(): RouterTriggerNote | undefined;
127
87
  /** Reset memory (hydrate and tests): the map and its thread index are rebuilt together. */
128
88
  export declare function resetTickets(): void;
@@ -209,7 +169,7 @@ export declare function listTickets(input?: ListTicketsInput): Array<Ticket>;
209
169
  * Gate every dispatch must pass: a dispatch always names a ticket, and that
210
170
  * ticket must be workable right now. The rules are enforced here (not in the
211
171
  * 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.
172
+ * across threads, or onto a ticket that already has a live (non-terminal) worker.
213
173
  */
214
174
  export declare const requireDispatchTicket: (ticketID: string | undefined, threadID: string | undefined) => Effect.Effect<Ticket, Tool.Error, never>;
215
175
  export interface LinkTicketInput {
@@ -2,8 +2,32 @@ import { Tool } from "@opencode/schema/tool";
2
2
  import { Effect } from "effect";
3
3
  import { createHash } from "node:crypto";
4
4
  import { normalizeDirectory } from "./dirs.js";
5
- import { store } from "./state.js";
5
+ import { ACTIVE_STATES, findActiveTicketForWorker, isTerminalState, PRIORITIES, TICKET_STATES, transitionError, workerCloseSummary, } from "./ticket-state.js";
6
+ import { isWorkerTerminal, workers } from "./worker-state.js";
6
7
  import { displayWidth, wrapText } from "./tools.js";
8
+ export { ACTIVE_STATES, allowedTransitions, canTransition, isTerminalState, TICKET_STATES, transitionError, } from "./ticket-state.js";
9
+ export { isWorkerTerminal, TERMINAL_WORKER_STATES } from "./worker-state.js";
10
+ /**
11
+ * Ticket (work order) store for the Super-agent P0.
12
+ *
13
+ * A ticket is the durable statement of "what the user asked for and how we
14
+ * will know it is done". The Router creates one before dispatching work; the
15
+ * dispatch records the worker session on it and the worker's terminal state
16
+ * closes it. Records live in KV under `ticket:<id>`; the `ticketidx:*` keys
17
+ * are derived accelerators, so hydration always rebuilds them from the
18
+ * record prefix (a lost index never loses a ticket).
19
+ *
20
+ * State machine:
21
+ *
22
+ * open ─┬─▶ in_progress ─┬─▶ done
23
+ * │ ├─▶ failed
24
+ * │ ├─▶ blocked ──▶ in_progress
25
+ * ├─▶ blocked └─▶ cancelled
26
+ * ├─▶ cancelled
27
+ * └─▶ superseded
28
+ *
29
+ * `done`, `failed`, `cancelled` and `superseded` are terminal.
30
+ */
7
31
  export const TICKET_PREFIX = "ticket:";
8
32
  export const TICKET_STATUS_PREFIX = "ticketidx:status:";
9
33
  export const TICKET_THREAD_PREFIX = "ticketidx:thread:";
@@ -20,47 +44,6 @@ export const INDEX_LIMIT = 200;
20
44
  export const MEMORY_LIMIT = 500;
21
45
  export const LIST_DEFAULT = 20;
22
46
  export const LIST_MAX = 100;
23
- const PRIORITIES = ["p0", "p1", "p2"];
24
- export const TICKET_STATES = [
25
- "open",
26
- "in_progress",
27
- "blocked",
28
- "done",
29
- "failed",
30
- "cancelled",
31
- "superseded",
32
- ];
33
- /** A ticket can be dispatched while it is one of these. */
34
- export const ACTIVE_STATES = ["open", "in_progress"];
35
- const TRANSITIONS = {
36
- // A ticket can close from any active state: `done`/`failed` also cover work
37
- // finished outside the worker (or a dispatch that never started).
38
- open: ["in_progress", "blocked", "done", "failed", "cancelled", "superseded"],
39
- in_progress: ["blocked", "done", "failed", "cancelled", "superseded"],
40
- blocked: ["in_progress", "done", "failed", "cancelled", "superseded"],
41
- done: [],
42
- failed: [],
43
- cancelled: [],
44
- superseded: [],
45
- };
46
- export function isTerminalState(state) {
47
- return TRANSITIONS[state].length === 0;
48
- }
49
- export function allowedTransitions(state) {
50
- return TRANSITIONS[state];
51
- }
52
- /** Same-state moves are no-ops, not transitions. */
53
- export function canTransition(from, to) {
54
- return from === to || TRANSITIONS[from].includes(to);
55
- }
56
- /** Human-readable reason a move is illegal, or `undefined` when it is allowed. */
57
- export function transitionError(from, to) {
58
- if (canTransition(from, to))
59
- return undefined;
60
- if (isTerminalState(from))
61
- return `ticket is already closed (${from}); cannot move to ${to}`;
62
- return `illegal transition ${from} -> ${to}; allowed: ${TRANSITIONS[from].join(", ")}`;
63
- }
64
47
  /** Lowercased, whitespace-collapsed objective, so wording drift does not defeat dedupe. */
65
48
  export function normalizeObjective(text) {
66
49
  return text.toLowerCase().replace(/\s+/g, " ").trim();
@@ -125,7 +108,10 @@ export function getTicket(ticketID) {
125
108
  /** Same-thread, same-dedupe-key ticket still open or in progress, if any. */
126
109
  export function findDuplicate(threadID, dedupeKey) {
127
110
  const ids = byThread.get(threadID);
128
- const candidates = ids === undefined ? [...tickets.values()] : [...ids].flatMap((id) => tickets.get(id) ?? []);
111
+ // No ids means this thread owns no in-memory ticket, so there is nothing to reuse.
112
+ if (ids === undefined)
113
+ return undefined;
114
+ const candidates = [...ids].flatMap((id) => tickets.get(id) ?? []);
129
115
  return candidates.find((ticket) => ticket.dedupeKey === dedupeKey && ACTIVE_STATES.includes(ticket.state));
130
116
  }
131
117
  export function isActive(ticket) {
@@ -159,7 +145,7 @@ export function activeTicketSummary(threadID, limit = 3) {
159
145
  }
160
146
  /** Active ticket for a worker session, used when a worker ends. */
161
147
  export function activeTicketForWorker(sessionID) {
162
- return [...tickets.values()].find((ticket) => ticket.workerSessionID === sessionID && ticket.state === "in_progress");
148
+ return findActiveTicketForWorker(tickets.values(), sessionID);
163
149
  }
164
150
  // ── validation ────────────────────────────────────────────────────────────────
165
151
  export function validateCreateInput(input) {
@@ -525,7 +511,7 @@ export function listTickets(input = {}) {
525
511
  * Gate every dispatch must pass: a dispatch always names a ticket, and that
526
512
  * ticket must be workable right now. The rules are enforced here (not in the
527
513
  * prompt) so a Router cannot dispatch without a ticket, onto a closed ticket,
528
- * across threads, or onto a ticket that already has a running worker.
514
+ * across threads, or onto a ticket that already has a live (non-terminal) worker.
529
515
  */
530
516
  export const requireDispatchTicket = Effect.fn("superagent.requireDispatchTicket")(function* (ticketID, threadID) {
531
517
  if (ticketID === undefined || ticketID.trim() === "") {
@@ -551,11 +537,16 @@ export const requireDispatchTicket = Effect.fn("superagent.requireDispatchTicket
551
537
  });
552
538
  }
553
539
  // One ticket never runs two workers at once: the previous worker must be
554
- // terminal (aborted, failed, done) before anything is dispatched again.
540
+ // terminal (done, failed, aborted) before anything is dispatched again.
541
+ // `idle` is NOT terminal — the session may still be resumed — so it blocks
542
+ // a re-dispatch exactly like `running`. A worker record that is gone blocks
543
+ // nothing: there is no live worker left to wait for.
555
544
  const workerID = ticket.workerSessionID;
556
- if (workerID !== undefined && store.workers.get(workerID)?.state === "running") {
545
+ const worker = workerID === undefined ? undefined : workers.get(workerID);
546
+ if (workerID !== undefined && worker !== undefined && !isWorkerTerminal(worker.state)) {
547
+ const article = worker.state === "idle" ? "an" : "a";
557
548
  return yield* new Tool.Error({
558
- message: `ticket ${ticket.id} already has a running worker ${workerID}; wait for it or abort_session it first, then ticket_create a new ticket for the re-dispatch`,
549
+ message: `ticket ${ticket.id} already has ${article} ${worker.state} worker ${workerID}; wait for it to finish, abort_session it, or ticket_create a new ticket for the re-dispatch`,
559
550
  });
560
551
  }
561
552
  return ticket;
@@ -602,7 +593,7 @@ export const closeTicketsForWorker = Effect.fn("superagent.closeTicketsForWorker
602
593
  return yield* closeTicket(ctx, {
603
594
  ticketID: ticket.id,
604
595
  outcome: input.outcome,
605
- summary: input.summary ?? input.note ?? `${input.outcome} (worker ${input.sessionID})`,
596
+ summary: workerCloseSummary(input),
606
597
  ...(input.now === undefined ? {} : { now: input.now }),
607
598
  });
608
599
  });
package/dist/src/tools.js CHANGED
@@ -49,6 +49,22 @@ export const ROUTER_TOOL_SPECS = [
49
49
  "派发成功即绑定工单:workerSessionID 写入、attempts+1、state → in_progress;Worker 结束自动结单。",
50
50
  ],
51
51
  },
52
+ {
53
+ name: "sleep",
54
+ group: "control",
55
+ description: 'Pause the router for a bounded number of seconds (default 30, range 1..600) with a real timer, never a busy loop, and return "slept Ns". In Goal mode, call it after dispatch to pause and give the Worker execution time before you check in again. Only the router may call it; tracked worker sessions neither see nor may call it.',
56
+ summary: "暂停指定秒数(默认 30,范围 1..600),给 Worker 预留执行时间。",
57
+ params: [{ name: "seconds", required: false, type: "number", note: "默认 30,1..600 的整数" }],
58
+ risk: "read",
59
+ permission: "status",
60
+ confirmation: "只读等待,自动执行(permission: status);非法秒数直接报错。",
61
+ usage: [
62
+ "Goal 模式:dispatch 后先 sleep,再回来汇总,不要空转轮询 Worker。",
63
+ "seconds 缺省 30;必须是 1..600 的整数,0/负数/超界/非数字都报错。",
64
+ "真实定时器(Effect.sleep):等待期间 Router 与其他任务照常推进。",
65
+ "仅 Router 可见:Worker 会话的工具列表里没有它,直调也会被拒绝。",
66
+ ],
67
+ },
52
68
  {
53
69
  name: "ticket_create",
54
70
  group: "dispatch",
@@ -134,11 +150,14 @@ export const ROUTER_TOOL_SPECS = [
134
150
  name: "status",
135
151
  group: "query",
136
152
  description: "Show a compact summary of the router, pending triggers and worker sessions.",
137
- summary: "查看 Router、pending 触发器数量与全部 Worker 的 thread/状态/最近报告。",
153
+ summary: "查看插件版本、Router、pending 触发器数量与全部 Worker 的 thread/状态/最近报告。",
138
154
  params: [],
139
155
  risk: "read",
140
156
  confirmation: "只读,自动执行(permission: status)。",
141
- usage: ["system 状态行只给计数,细节用 status / thread_detail。"],
157
+ usage: [
158
+ "首行是插件版本与「升级后需重启网关」提示,用来判断网关是否还在跑旧代码。",
159
+ "system 状态行只给计数,细节用 status / thread_detail。",
160
+ ],
142
161
  },
143
162
  {
144
163
  name: "thread_detail",
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Plugin version, read from the package's own `package.json`.
3
+ *
4
+ * The version is the only thing this module exposes: it is deliberately not a
5
+ * diagnostics dump, so logging it can never leak config, tokens, absolute
6
+ * paths or session ids.
7
+ */
8
+ /** Shown when no candidate `package.json` can be read or parsed. */
9
+ export declare const UNKNOWN_VERSION = "0.0.0-unknown";
10
+ /** Extract `version` from raw `package.json` text; `undefined` when unusable. */
11
+ export declare function versionFromPackageJson(raw: string): string | undefined;
12
+ export type ReadFile = (file: string) => string | undefined;
13
+ /**
14
+ * Read the first usable candidate, falling back to {@link UNKNOWN_VERSION}.
15
+ *
16
+ * `read` is injectable so the fallback path is testable without touching the
17
+ * filesystem: pass `() => undefined` for a missing package, or a stub that
18
+ * returns unparsable JSON.
19
+ */
20
+ export declare function loadVersion(read?: ReadFile): string;
21
+ /** Cached plugin version; the process never changes version at runtime. */
22
+ export declare function pluginVersion(): string;
23
+ /**
24
+ * One-line startup notice. Says what is loaded and that upgrading an already
25
+ * running gateway is not enough — the plugin has no hot reload, so a service
26
+ * restart is required before the new code is active.
27
+ */
28
+ export declare function versionNotice(): string;