@sellable/mcp 0.1.532 → 0.1.533

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.
@@ -19,6 +19,34 @@ const REFILL_DONE_REASONS = new Set([
19
19
  "not_an_evergreen_workspace",
20
20
  "no_refillable_campaigns",
21
21
  ]);
22
+ const SCHEDULER_RUN_ENVELOPE_STATUSES = new Set([
23
+ "ran",
24
+ "attached",
25
+ "backoff",
26
+ "window_closed_noop",
27
+ "failed",
28
+ ]);
29
+ const SCHEDULER_RUN_RECEIPT_STATUSES = new Set([
30
+ "ran",
31
+ "window_closed_noop",
32
+ "failed",
33
+ ]);
34
+ const SCHEDULER_RUN_SKIP_REASONS = [
35
+ "window_closed",
36
+ "daily_limit",
37
+ "cooldown",
38
+ "sender_gate",
39
+ "credit_threshold",
40
+ "billing_blocked",
41
+ "duplicate_lead",
42
+ "no_senders",
43
+ "other",
44
+ ];
45
+ const SCHEDULER_RUN_HARD_BLOCK_REASONS = new Set([
46
+ "billing_blocked",
47
+ "credit_threshold",
48
+ "cooldown",
49
+ ]);
22
50
  function isRecord(value) {
23
51
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
24
52
  }
@@ -40,6 +68,89 @@ function arrayValue(value) {
40
68
  function hasBlocker(value, blocker) {
41
69
  return isRecord(value) && value.blocker === blocker;
42
70
  }
71
+ function schedulerRunSkipReasons(value) {
72
+ const raw = recordValue(value) ?? {};
73
+ const normalized = {};
74
+ for (const reason of SCHEDULER_RUN_SKIP_REASONS) {
75
+ normalized[reason] = numberValue(raw[reason]) ?? 0;
76
+ }
77
+ return normalized;
78
+ }
79
+ function dominantSchedulerRunSkipReason(skipReasons) {
80
+ let dominant = null;
81
+ let dominantCount = 0;
82
+ for (const reason of SCHEDULER_RUN_SKIP_REASONS) {
83
+ const count = skipReasons[reason] ?? 0;
84
+ if (count > dominantCount) {
85
+ dominant = reason;
86
+ dominantCount = count;
87
+ }
88
+ }
89
+ return dominantCount > 0 ? dominant : null;
90
+ }
91
+ function sanitizeSchedulerRunReceipt(raw) {
92
+ const envelope = recordValue(raw);
93
+ if (!envelope)
94
+ return null;
95
+ const status = stringValue(envelope.status);
96
+ if (!status || !SCHEDULER_RUN_ENVELOPE_STATUSES.has(status))
97
+ return null;
98
+ const retryAfterMs = envelope.retryAfterMs == null ? null : numberValue(envelope.retryAfterMs);
99
+ if (envelope.retryAfterMs != null && retryAfterMs == null)
100
+ return null;
101
+ const receiptInput = envelope.receipt;
102
+ let receipt = null;
103
+ let dominantSkipReason = null;
104
+ if (receiptInput != null) {
105
+ const rawReceipt = recordValue(receiptInput);
106
+ if (!rawReceipt)
107
+ return null;
108
+ const receiptStatus = stringValue(rawReceipt.status);
109
+ if (!receiptStatus || !SCHEDULER_RUN_RECEIPT_STATUSES.has(receiptStatus)) {
110
+ return null;
111
+ }
112
+ const cellsConsidered = numberValue(rawReceipt.cellsConsidered);
113
+ const cellsScheduled = numberValue(rawReceipt.cellsScheduled);
114
+ const cellsSkipped = numberValue(rawReceipt.cellsSkipped);
115
+ const cellsDeferred = numberValue(rawReceipt.cellsDeferred);
116
+ const tablesFilteredForNoCapacity = numberValue(rawReceipt.tablesFilteredForNoCapacity);
117
+ if (cellsConsidered == null ||
118
+ cellsScheduled == null ||
119
+ cellsSkipped == null ||
120
+ cellsDeferred == null ||
121
+ tablesFilteredForNoCapacity == null) {
122
+ return null;
123
+ }
124
+ const skipReasons = schedulerRunSkipReasons(rawReceipt.skipReasons);
125
+ dominantSkipReason = dominantSchedulerRunSkipReason(skipReasons);
126
+ receipt = {
127
+ status: receiptStatus,
128
+ cellsConsidered,
129
+ cellsScheduled,
130
+ cellsSkipped,
131
+ cellsDeferred,
132
+ tablesFilteredForNoCapacity,
133
+ skipReasons,
134
+ };
135
+ }
136
+ return {
137
+ status,
138
+ retryAfterMs,
139
+ receipt,
140
+ dominantSkipReason,
141
+ hardBlocked: dominantSkipReason != null &&
142
+ SCHEDULER_RUN_HARD_BLOCK_REASONS.has(dominantSkipReason),
143
+ };
144
+ }
145
+ function schedulerRunReceiptIsFreshZeroScheduled(schedulerRunReceipt) {
146
+ const status = stringValue(schedulerRunReceipt?.status);
147
+ const fresh = status === "ran" || status === "window_closed_noop";
148
+ if (!fresh)
149
+ return false;
150
+ const receipt = recordValue(schedulerRunReceipt?.receipt);
151
+ return (status === "window_closed_noop" ||
152
+ numberValue(receipt?.cellsScheduled) === 0);
153
+ }
43
154
  function isLeaseLost(value) {
44
155
  return hasBlocker(value, "lease_lost");
45
156
  }
@@ -1013,22 +1124,34 @@ async function verifySchedulerWait(input, deps, ctx, action, budgets) {
1013
1124
  const enteredAt = stringValue(progress.schedulerWaitEnteredAt) ??
1014
1125
  (deps.now?.() ?? new Date()).toISOString();
1015
1126
  const jitFired = booleanValue(progress.schedulerJitFired) ?? false;
1127
+ let schedulerRunReceipt = recordValue(progress.schedulerRunReceipt) ?? null;
1016
1128
  if (!jitFired) {
1017
1129
  const senderId = actionSenderId(action);
1018
1130
  if (senderId) {
1019
1131
  await deps.executors.refreshPaidInmailCreditsWithRetry(senderId, input.workspaceId);
1020
1132
  }
1021
1133
  if (deps.requestSchedulerRun) {
1022
- await deps.requestSchedulerRun(input.workspaceId);
1134
+ try {
1135
+ schedulerRunReceipt = sanitizeSchedulerRunReceipt(await deps.requestSchedulerRun(input.workspaceId));
1136
+ }
1137
+ catch {
1138
+ schedulerRunReceipt = null;
1139
+ }
1023
1140
  }
1024
1141
  ctx.runState = mergeRunState(ctx.runState, {
1025
1142
  progress: mergeProgress(ctx, {
1026
1143
  schedulerWaitEnteredAt: enteredAt,
1027
1144
  schedulerJitFired: true,
1145
+ ...(schedulerRunReceipt ? { schedulerRunReceipt } : {}),
1028
1146
  }),
1029
1147
  });
1030
1148
  }
1031
- for (let poll = 0; poll < budgets.maxSchedulerReadbacks; poll += 1) {
1149
+ const zeroScheduledFresh = schedulerRunReceiptIsFreshZeroScheduled(schedulerRunReceipt);
1150
+ // EDGE-2: the on-demand run only executes placement; cron can still process
1151
+ // due/timed-out cells later. We still do one readback, then avoid burning the
1152
+ // full wait budget when the fresh receipt says nothing was placeable now.
1153
+ const readbackBudget = zeroScheduledFresh ? 1 : budgets.maxSchedulerReadbacks;
1154
+ for (let poll = 0; poll < readbackBudget; poll += 1) {
1032
1155
  const fresh = await deps.readPlan({
1033
1156
  workspaceId: input.workspaceId,
1034
1157
  intent: input.intent,
package/dist/server.js CHANGED
@@ -45,6 +45,7 @@ import { allTools } from "./tools/registry.js";
45
45
  import { getRows, getTableRows, getTableRowsMinimal } from "./tools/rows.js";
46
46
  import { addRubricItem, checkRubric, deleteRubricItem, draftRubrics, saveRubrics, selectNecessaryRubrics, updateRubricItem, waitForRubricResults, } from "./tools/rubrics.js";
47
47
  import { getSchedulerFillCapacity } from "./tools/scheduler-fill-capacity.js";
48
+ import { runSchedulerSweep } from "./tools/scheduler-run.js";
48
49
  import { getSenderRoutingTool, setSenderRoutingTool, } from "./tools/sender-routing.js";
49
50
  import { getSender, listSenders, refreshPaidInmailCredits, } from "./tools/senders.js";
50
51
  import { attachRecommendedSequence, attachSequence, createWorkflowTable, } from "./tools/sequencer.js";
@@ -235,6 +236,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
235
236
  case "get_scheduler_fill_capacity":
236
237
  result = await getSchedulerFillCapacity(args);
237
238
  break;
239
+ case "run_scheduler_sweep":
240
+ result = await runSchedulerSweep(args);
241
+ break;
238
242
  case "refill_sends":
239
243
  result = await executeRefillSendsCommand(args);
240
244
  break;
@@ -5,6 +5,7 @@ import { runRefillV2Loop } from "../refill-run-loop.js";
5
5
  import { getPrepareCampaignMessagesStatus } from "./campaign-message-preparation.js";
6
6
  import { getRefillPlanV2 } from "./evergreen-refill-plan.js";
7
7
  import { executeOneYoloPrimitive, executeStartCampaignPrimitive, prepareRowSelectorValue, refillPrepareRequestHash, refreshPaidInmailCreditsWithRetry, } from "./refill-executors.js";
8
+ import { runSchedulerSweep } from "./scheduler-run.js";
8
9
  const FORBIDDEN_ACTIONS = [
9
10
  "Do not schedule sends.",
10
11
  "Do not send messages.",
@@ -152,6 +153,7 @@ export async function refillSendsV2Command(input) {
152
153
  localState: {
153
154
  writeRefillWorkspaceState,
154
155
  },
156
+ requestSchedulerRun: (workspaceId) => runSchedulerSweep({ workspaceId, action: "run" }),
155
157
  });
156
158
  return {
157
159
  ...(await maybeAddLostFenceGuidance(result, { ...input, workspaceId })),
@@ -7370,6 +7370,25 @@ export declare const allTools: ({
7370
7370
  };
7371
7371
  required: string[];
7372
7372
  };
7373
+ } | {
7374
+ name: string;
7375
+ description: string;
7376
+ inputSchema: {
7377
+ type: string;
7378
+ properties: {
7379
+ workspaceId: {
7380
+ type: string;
7381
+ description: string;
7382
+ };
7383
+ action: {
7384
+ type: string;
7385
+ enum: string[];
7386
+ description: string;
7387
+ };
7388
+ };
7389
+ required: string[];
7390
+ additionalProperties: boolean;
7391
+ };
7373
7392
  } | {
7374
7393
  name: string;
7375
7394
  description: string;
@@ -40,6 +40,7 @@ import { refillTargetPlanToolDefinitions } from "./refill-target-plan.js";
40
40
  import { rowToolDefinitions } from "./rows.js";
41
41
  import { rubricToolDefinitions } from "./rubrics.js";
42
42
  import { schedulerFillCapacityToolDefinitions } from "./scheduler-fill-capacity.js";
43
+ import { schedulerRunToolDefinitions } from "./scheduler-run.js";
43
44
  import { senderRoutingToolDefinitions } from "./sender-routing.js";
44
45
  import { senderToolDefinitions } from "./senders.js";
45
46
  import { sequencerToolDefinitions } from "./sequencer.js";
@@ -57,6 +58,7 @@ export const allTools = [
57
58
  ...refillPlanV2ToolDefinitions,
58
59
  ...refillTargetPlanToolDefinitions,
59
60
  ...schedulerFillCapacityToolDefinitions,
61
+ ...schedulerRunToolDefinitions,
60
62
  ...refillSendsToolDefinitions,
61
63
  ...refillSendsV2ToolDefinitions,
62
64
  ...setupEvergreenCampaignsToolDefinitions,
@@ -0,0 +1,27 @@
1
+ type SchedulerRunAction = "run" | "status";
2
+ type RunSchedulerSweepInput = {
3
+ workspaceId: string;
4
+ action?: SchedulerRunAction;
5
+ };
6
+ export declare const schedulerRunToolDefinitions: {
7
+ name: string;
8
+ description: string;
9
+ inputSchema: {
10
+ type: string;
11
+ properties: {
12
+ workspaceId: {
13
+ type: string;
14
+ description: string;
15
+ };
16
+ action: {
17
+ type: string;
18
+ enum: string[];
19
+ description: string;
20
+ };
21
+ };
22
+ required: string[];
23
+ additionalProperties: boolean;
24
+ };
25
+ }[];
26
+ export declare function runSchedulerSweep(input: RunSchedulerSweepInput): Promise<unknown>;
27
+ export {};
@@ -0,0 +1,45 @@
1
+ import { getApi } from "../api.js";
2
+ import { normalizeExplicitWorkspaceId, workspaceRequestOptions, } from "./workspace-context.js";
3
+ async function postSchedulerRun(body, workspaceId) {
4
+ const api = getApi();
5
+ const requestOptions = workspaceRequestOptions(workspaceId);
6
+ return requestOptions
7
+ ? api.post("/api/v3/mcp/scheduler-run", body, requestOptions)
8
+ : api.post("/api/v3/mcp/scheduler-run", body);
9
+ }
10
+ export const schedulerRunToolDefinitions = [
11
+ {
12
+ name: "run_scheduler_sweep",
13
+ description: 'Trigger the product scheduler placement sweep for one explicit workspace now, or read the last on-demand scheduler run status with action "status". A run returns a synchronous envelope with status ran, attached, backoff, window_closed_noop, or failed; retryAfterMs for backoff replays; and a receipt with cellsConsidered, cellsScheduled, cellsSkipped, cellsDeferred, deterministic skipReasons, and tablesFilteredForNoCapacity. It places cells within existing scheduler gates only: it can move placement earlier, but it cannot bypass sending windows, daily limits, cooldowns, sender gates, billing, or credit thresholds, and it never sends messages directly. Repeated calls inside the backoff window replay the last receipt verbatim. Status is read-only and scoped to the last on-demand run only; cron sweeps are not recorded here.',
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ workspaceId: {
18
+ type: "string",
19
+ description: "Explicit request-scoped workspace id for scheduled/yolo refill automation. Pass this instead of switching the shared active workspace.",
20
+ },
21
+ action: {
22
+ type: "string",
23
+ enum: ["run", "status"],
24
+ description: 'Use "run" to trigger a placement sweep now. Use "status" for a read-only view of the last on-demand run. Defaults to "run".',
25
+ },
26
+ },
27
+ required: ["workspaceId"],
28
+ additionalProperties: false,
29
+ },
30
+ },
31
+ ];
32
+ export async function runSchedulerSweep(input) {
33
+ const workspaceId = normalizeExplicitWorkspaceId(input.workspaceId);
34
+ if (!workspaceId) {
35
+ throw new Error("workspaceId is required for run_scheduler_sweep.");
36
+ }
37
+ const action = input.action ?? "run";
38
+ if (action !== "run" && action !== "status") {
39
+ throw new Error('action must be "run" or "status" for run_scheduler_sweep.');
40
+ }
41
+ return postSchedulerRun({
42
+ workspaceId,
43
+ action,
44
+ }, workspaceId);
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.532",
3
+ "version": "0.1.533",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code, Codex, and Hermes campaign workflows",
6
6
  "main": "dist/index.js",
@@ -6,6 +6,7 @@ allowed-tools:
6
6
  - mcp__sellable__refill_sends
7
7
  - mcp__sellable__get_refill_target_plan
8
8
  - mcp__sellable__get_scheduler_fill_capacity
9
+ - mcp__sellable__run_scheduler_sweep
9
10
  - mcp__sellable__refresh_paid_inmail_credits
10
11
  - mcp__sellable__get_subskill_asset
11
12
  - mcp__sellable__get_auth_status
@@ -114,10 +115,11 @@ or install-time workspace mapping. Pass `workspaceId` on every scheduled or
114
115
  `--yolo` refill tool call, including setup/read calls such as
115
116
  `refill_sends`, `get_refill_target_plan`, `list_senders`,
116
117
  `get_sender_routing`, `resolve_campaign_fill_route`,
117
- `get_campaign_refill_state`, `get_scheduler_fill_capacity`, and any later
118
- refill mutation covered by the packet. Missing `workspaceId` in scheduled or
119
- `--yolo` mode is a blocker; stop with `WORKSPACE_REQUIRED` instead of running
120
- against an implicit or guessed workspace.
118
+ `get_campaign_refill_state`, `get_scheduler_fill_capacity`,
119
+ `run_scheduler_sweep`, and any later refill mutation covered by the packet.
120
+ Missing `workspaceId` in scheduled or `--yolo` mode is a blocker; stop with
121
+ `WORKSPACE_REQUIRED` instead of running against an implicit or guessed
122
+ workspace.
121
123
 
122
124
  Do not solve scheduled or `--yolo` workspace uncertainty by changing the shared
123
125
  active workspace. Manual interactive workspace switching remains a separate
@@ -269,6 +271,10 @@ need raw proof, call the read-only `get_scheduler_fill_capacity` query for the
269
271
  same sender/action/date; it tells the MCP how many cells the product scheduler
270
272
  will try to place and does not import, approve, schedule, refresh credits, or
271
273
  mutate.
274
+ When the refill loop has ready rows and needs scheduler pickup now, use
275
+ `run_scheduler_sweep` with the same explicit `workspaceId`; it can place cells
276
+ within existing scheduler gates and returns the receipt, but it never sends or
277
+ bypasses limits.
272
278
  If the target plan is complete by projected coverage, report that the selected
273
279
  target is already filled and no-op without asking for approval. If the ready
274
280
  buffer covers the projected gap, paid InMail credit facts are fresh for every
@@ -6,6 +6,7 @@ allowed-tools:
6
6
  - mcp__sellable__get_subskill_asset
7
7
  - mcp__sellable__get_refill_target_plan
8
8
  - mcp__sellable__get_scheduler_fill_capacity
9
+ - mcp__sellable__run_scheduler_sweep
9
10
  - mcp__sellable__refresh_paid_inmail_credits
10
11
  - mcp__sellable__list_senders
11
12
  - mcp__sellable__get_sender_routing
@@ -67,11 +68,12 @@ request-scoped `workspaceId`. Pass that same `workspaceId` on every refill tool
67
68
  call in this workflow: `get_refill_target_plan`, `list_senders`,
68
69
  `get_sender_routing`, `resolve_campaign_fill_route`,
69
70
  `get_campaign_refill_state`, `get_scheduler_fill_capacity`,
70
- `refresh_paid_inmail_credits`, source import/readiness calls, preparation calls,
71
- approval calls, and campaign start calls. Missing `workspaceId` in scheduled or
72
- `--yolo` mode is a blocker; return or report `WORKSPACE_REQUIRED` instead of
73
- falling back to shared config state. Manual interactive workspace switching is
74
- diagnostic setup only and is not an automation control path.
71
+ `run_scheduler_sweep`, `refresh_paid_inmail_credits`, source import/readiness
72
+ calls, preparation calls, approval calls, and campaign start calls. Missing
73
+ `workspaceId` in scheduled or `--yolo` mode is a blocker; return or report
74
+ `WORKSPACE_REQUIRED` instead of falling back to shared config state. Manual
75
+ interactive workspace switching is diagnostic setup only and is not an
76
+ automation control path.
75
77
 
76
78
  Goal-mode continuation: a skill cannot create or invoke `/goal` by itself. When
77
79
  this workflow is already running inside an active Codex goal, keep that goal
@@ -186,6 +188,10 @@ files or memory.
186
188
  how many cells the product scheduler will try to place for that sender; it
187
189
  does not create rows, import, approve, schedule, refresh paid-InMail credits,
188
190
  or mutate thresholds.
191
+ When ready rows exist and the wait is for scheduler pickup, call
192
+ `run_scheduler_sweep` with the same explicit `workspaceId` to request the
193
+ product scheduler placement pass now and read its receipt. This may place
194
+ cells within existing gates, never sends messages, and never bypasses limits.
189
195
  If `status:"complete"`, report the target, selected dates, sent count,
190
196
  scheduled count, projected count, campaign ids, and no-op proof without
191
197
  asking for approval or mutating.