@desplega.ai/agent-swarm 1.93.0 → 1.95.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.
Files changed (85) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +180 -1
  3. package/package.json +4 -3
  4. package/src/be/db.ts +74 -9
  5. package/src/be/migrations/090_model_tiers.sql +2 -0
  6. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  7. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  8. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  9. package/src/be/migrations/094_mcp_extra_authorize_params.sql +4 -0
  10. package/src/be/migrations/runner.ts +52 -0
  11. package/src/be/modelsdev-cache.json +2060 -198
  12. package/src/be/scripts/boot-reembed.ts +74 -0
  13. package/src/be/scripts/db.ts +19 -3
  14. package/src/be/seed/index.ts +1 -1
  15. package/src/be/seed/registry.ts +2 -2
  16. package/src/be/seed/runner.ts +5 -5
  17. package/src/be/seed/types.ts +6 -1
  18. package/src/be/seed-pricing.ts +1 -0
  19. package/src/be/seed-scripts/index.ts +3 -2
  20. package/src/be/skill-sync.ts +4 -4
  21. package/src/be/swarm-config-guard.ts +8 -0
  22. package/src/commands/provider-credentials.ts +14 -8
  23. package/src/commands/runner.ts +84 -13
  24. package/src/http/index.ts +13 -2
  25. package/src/http/mcp-oauth.ts +14 -0
  26. package/src/http/metrics.ts +55 -6
  27. package/src/http/schedules.ts +16 -15
  28. package/src/http/script-runs.ts +7 -1
  29. package/src/http/scripts.ts +147 -1
  30. package/src/http/tasks.ts +7 -0
  31. package/src/model-tiers.ts +140 -0
  32. package/src/oauth/mcp-wrapper.ts +14 -0
  33. package/src/providers/claude-managed-models.ts +9 -0
  34. package/src/providers/codex-skill-resolver.ts +22 -8
  35. package/src/providers/opencode-adapter.ts +21 -2
  36. package/src/providers/pi-mono-adapter.ts +143 -26
  37. package/src/providers/types.ts +12 -0
  38. package/src/scheduler/scheduler.ts +22 -34
  39. package/src/server-user.ts +8 -2
  40. package/src/slack/responses.ts +39 -11
  41. package/src/slack/watcher.ts +121 -8
  42. package/src/tests/agents-list-model-display.test.ts +13 -0
  43. package/src/tests/aws-error-classifier.test.ts +148 -0
  44. package/src/tests/claude-managed-adapter.test.ts +12 -0
  45. package/src/tests/context-window.test.ts +7 -0
  46. package/src/tests/credential-check.test.ts +185 -46
  47. package/src/tests/harness-provider-resolution.test.ts +23 -0
  48. package/src/tests/http-api-integration.test.ts +19 -0
  49. package/src/tests/mcp-oauth-queries.test.ts +71 -1
  50. package/src/tests/mcp-oauth-wrapper.test.ts +109 -0
  51. package/src/tests/metrics-http.test.ts +137 -3
  52. package/src/tests/migration-046-budgets.test.ts +33 -0
  53. package/src/tests/migration-runner-regressions.test.ts +69 -0
  54. package/src/tests/model-control.test.ts +162 -46
  55. package/src/tests/opencode-adapter.test.ts +38 -1
  56. package/src/tests/pi-mono-adapter.test.ts +319 -0
  57. package/src/tests/provider-command-format.test.ts +12 -0
  58. package/src/tests/providers/pi-cost.test.ts +9 -0
  59. package/src/tests/runner-fallback-output.test.ts +50 -0
  60. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  61. package/src/tests/scripts-embeddings.test.ts +90 -0
  62. package/src/tests/seed.test.ts +26 -1
  63. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  64. package/src/tests/skill-fs-writer.test.ts +7 -1
  65. package/src/tests/skill-sync.test.ts +15 -3
  66. package/src/tests/slack-watcher.test.ts +66 -0
  67. package/src/tests/workflow-agent-task.test.ts +5 -2
  68. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  69. package/src/tools/mcp-servers/mcp-server-create.ts +7 -0
  70. package/src/tools/mcp-servers/mcp-server-update.ts +8 -0
  71. package/src/tools/memory-get.ts +11 -0
  72. package/src/tools/memory-search.ts +18 -0
  73. package/src/tools/schedules/create-schedule.ts +71 -70
  74. package/src/tools/schedules/update-schedule.ts +43 -31
  75. package/src/tools/send-task.ts +16 -5
  76. package/src/tools/task-action.ts +11 -3
  77. package/src/types.ts +30 -0
  78. package/src/utils/aws-error-classifier.ts +97 -0
  79. package/src/utils/context-window.ts +2 -0
  80. package/src/utils/credentials.test.ts +68 -0
  81. package/src/utils/credentials.ts +44 -3
  82. package/src/utils/pretty-print.ts +25 -10
  83. package/src/utils/skill-fs-writer.ts +11 -3
  84. package/src/workflows/engine.ts +3 -2
  85. package/src/workflows/executors/agent-task.ts +3 -1
@@ -20,6 +20,7 @@ import {
20
20
  import { validateOpencodeCredentials } from "../utils/credentials";
21
21
  import { fetchInstalledMcpServers } from "../utils/mcp-server-fetcher";
22
22
  import { scrubSecrets } from "../utils/secret-scrubber";
23
+ import { resolveSlashSkillPrompt } from "./codex-skill-resolver";
23
24
  import { CTX_MODE_NUDGE_EVERY } from "./ctx-mode-env";
24
25
  import { readPkgVersion } from "./harness-version";
25
26
  import type {
@@ -102,6 +103,13 @@ function isAssistantMessage(msg: unknown): msg is AssistantMessage {
102
103
  }
103
104
 
104
105
  const DOCKER_PLUGIN_PATH = "/home/worker/.config/opencode/plugins/agent-swarm.ts";
106
+
107
+ function defaultOpencodeSkillsDir(): string {
108
+ if (process.env.OPENCODE_SKILLS_DIR) {
109
+ return process.env.OPENCODE_SKILLS_DIR;
110
+ }
111
+ return join(process.env.HOME ?? "/home/worker", ".opencode", "skills");
112
+ }
105
113
  const MODEL_CACHE_REFRESH_TIMEOUT_MS = 15_000;
106
114
 
107
115
  function isOpenRouterModel(model: string | undefined): boolean {
@@ -286,10 +294,15 @@ export class OpencodeSession implements ProviderSession {
286
294
  type: "session_init",
287
295
  sessionId: this._sessionId,
288
296
  provider,
297
+ harnessVariant: "stock",
289
298
  ...(harnessVariantMeta ? { harnessVariantMeta } : {}),
290
299
  });
291
300
  }
292
301
 
302
+ emitProviderEvent(event: ProviderEvent): void {
303
+ this.emit(event);
304
+ }
305
+
293
306
  onEvent(listener: (event: ProviderEvent) => void): void {
294
307
  const wasEmpty = this.listeners.length === 0;
295
308
  this.listeners.push(listener);
@@ -737,13 +750,19 @@ export class OpencodeAdapter implements ProviderAdapter {
737
750
 
738
751
  let promptRefreshAttempted = false;
739
752
  let promptRefreshPromise: Promise<boolean> | undefined;
753
+ let session: OpencodeSession | undefined;
740
754
  const sendPrompt = async () => {
755
+ const resolvedPrompt = await resolveSlashSkillPrompt(config.prompt, {
756
+ providerLabel: "opencode",
757
+ skillsDir: defaultOpencodeSkillsDir(),
758
+ emit: (event) => session?.emitProviderEvent(event),
759
+ });
741
760
  await client.session.prompt({
742
761
  path: { id: sessionId },
743
762
  query: { directory: config.cwd },
744
763
  body: {
745
764
  agent: agentName,
746
- parts: [{ type: "text", text: config.prompt }],
765
+ parts: [{ type: "text", text: resolvedPrompt }],
747
766
  },
748
767
  });
749
768
  };
@@ -759,7 +778,7 @@ export class OpencodeAdapter implements ProviderAdapter {
759
778
  return await promptRefreshPromise;
760
779
  };
761
780
 
762
- const session = new OpencodeSession(
781
+ session = new OpencodeSession(
763
782
  sessionId,
764
783
  server,
765
784
  config.model,
@@ -25,6 +25,7 @@ import {
25
25
  SessionManager,
26
26
  } from "@earendil-works/pi-coding-agent";
27
27
  import { type TSchema, Type } from "typebox";
28
+ import { classifyAwsSdkError } from "../utils/aws-error-classifier";
28
29
  import { scrubSecrets } from "../utils/secret-scrubber";
29
30
  import { readPkgVersion } from "./harness-version";
30
31
  import { createSwarmHooksExtension } from "./pi-mono-extension";
@@ -73,40 +74,85 @@ function modelToCredKeys(modelStr: string | undefined): string[] | null {
73
74
  return null;
74
75
  }
75
76
 
77
+ /**
78
+ * Run a single `ListFoundationModels` call against the AWS Bedrock management
79
+ * API to verify that the active credential chain is valid for Bedrock in the
80
+ * configured region. Returns the client directly (callers discard the model
81
+ * list — only the throw/no-throw distinction is the signal).
82
+ *
83
+ * Dynamically imported so the API binary never loads `@aws-sdk/client-bedrock`.
84
+ * Tests inject a stub via `CredCheckOptions.bedrockProbe` instead.
85
+ */
86
+ async function runBedrockSdkProbe(region: string): Promise<void> {
87
+ const { BedrockClient, ListFoundationModelsCommand } = await import("@aws-sdk/client-bedrock");
88
+ const client = new BedrockClient({ region });
89
+ await client.send(new ListFoundationModelsCommand({}));
90
+ }
91
+
76
92
  /**
77
93
  * Pi-mono is satisfied by ANY of:
78
- * 1. `MODEL_OVERRIDE` selects the `amazon-bedrock` provider — credential
79
- * resolution is delegated to the AWS SDK's default chain at first
80
- * inference call. agent-swarm does no presence check; if creds are
81
- * missing the SDK error surfaces in the session log.
94
+ * 1. `BEDROCK_AUTH_MODE=sdk` or `MODEL_OVERRIDE` selects the
95
+ * `amazon-bedrock` provider (prefix-inference fallback when
96
+ * `BEDROCK_AUTH_MODE` is absent). A real `ListFoundationModels` probe
97
+ * is issued via the AWS SDK default credential chain. Success
98
+ * `ready:true, satisfiedBy:"sdk-delegated"`; failure → `ready:false`
99
+ * with a classified hint. The probe is worker-only (the pi dynamic-import
100
+ * arm in `checkProviderCredentials`); the API binary never imports the SDK.
82
101
  * 2. `~/.pi/agent/auth.json` exists.
83
- * 3. `MODEL_OVERRIDE` is set to a provider-prefixed model — only the
84
- * matching provider's key is required.
102
+ * 3. `MODEL_OVERRIDE` is set to a non-Bedrock provider-prefixed model — only
103
+ * the matching provider's key is required.
85
104
  * 4. `MODEL_OVERRIDE` is empty / unprefixed — any one of the supported
86
105
  * keys (ANTHROPIC_API_KEY / OPENROUTER_API_KEY / OPENAI_API_KEY) is
87
106
  * enough.
88
107
  *
89
- * Bedrock is checked first so a stale `auth.json` (Anthropic / OpenRouter
90
- * creds from a previous login) doesn't get falsely reported as the
91
- * satisfying source when the model is actually going to AWS.
108
+ * The Bedrock branch is checked first so a stale `auth.json` (Anthropic /
109
+ * OpenRouter creds from a previous login) doesn't get falsely reported as
110
+ * the satisfying source when the model is actually going to AWS.
92
111
  */
93
- export function checkPiMonoCredentials(
112
+ export async function checkPiMonoCredentials(
94
113
  env: Record<string, string | undefined>,
95
114
  opts: CredCheckOptions = {},
96
- ): CredStatus {
97
- if (env.MODEL_OVERRIDE?.toLowerCase().startsWith("amazon-bedrock/")) {
98
- return {
99
- ready: true,
100
- missing: [],
101
- satisfiedBy: "sdk-delegated",
102
- hint: "AWS SDK will resolve credentials at first Bedrock call (env, ~/.aws/*, SSO, IMDS, etc.).",
103
- };
115
+ ): Promise<CredStatus> {
116
+ // Determine Bedrock SDK mode:
117
+ // - Explicit: BEDROCK_AUTH_MODE=sdk
118
+ // - Fallback: BEDROCK_AUTH_MODE absent AND MODEL_OVERRIDE starts with
119
+ // "amazon-bedrock/" (preserves today's prefix-inference semantics)
120
+ // BEDROCK_AUTH_MODE=bearer is declared/validated but the full bearer-token
121
+ // path is out of scope for PR1 it falls through to the standard auth check.
122
+ const bedrockAuthMode = env.BEDROCK_AUTH_MODE?.toLowerCase();
123
+ const isBedrockSdk =
124
+ bedrockAuthMode === "sdk" ||
125
+ (bedrockAuthMode === undefined &&
126
+ env.MODEL_OVERRIDE?.toLowerCase().startsWith("amazon-bedrock/"));
127
+
128
+ if (isBedrockSdk) {
129
+ const region = env.AWS_REGION ?? "us-east-1";
130
+ const probe = opts.bedrockProbe ?? (() => runBedrockSdkProbe(region));
131
+ try {
132
+ await probe();
133
+ return {
134
+ ready: true,
135
+ missing: [],
136
+ satisfiedBy: "sdk-delegated",
137
+ hint: `AWS SDK credentials verified via ListFoundationModels (region: ${region}).`,
138
+ };
139
+ } catch (err) {
140
+ const errorMessage = err instanceof Error ? err.message : String(err);
141
+ const classification = classifyAwsSdkError(errorMessage);
142
+ return {
143
+ ready: false,
144
+ missing: [],
145
+ hint:
146
+ classification?.message ??
147
+ `AWS Bedrock credential probe failed (region: ${region}): ${errorMessage}`,
148
+ };
149
+ }
104
150
  }
105
151
 
106
152
  const homeDir = opts.homeDir ?? env.HOME ?? "/root";
107
- const probe = opts.fs?.existsSync ?? existsSync;
153
+ const fsProbe = opts.fs?.existsSync ?? existsSync;
108
154
  const authFile = `${homeDir}/.pi/agent/auth.json`;
109
- if (probe(authFile)) {
155
+ if (fsProbe(authFile)) {
110
156
  return { ready: true, missing: [], satisfiedBy: "file" };
111
157
  }
112
158
 
@@ -361,6 +407,18 @@ export class PiMonoSession implements ProviderSession {
361
407
  * surface it directly.
362
408
  */
363
409
  private prevOutputTokens = 0;
410
+ /**
411
+ * Terminal error message captured from structured pi-coding-agent events.
412
+ *
413
+ * Set by `message_end` (assistant turn with `stopReason==='error'` — covers
414
+ * NON-retryable failures, including AWS auth which never enters pi's retry
415
+ * loop) and by `auto_retry_end` with `success:false` (the definitive terminal
416
+ * failure after the retryable class — throttle / 5xx / timeout — exhausts).
417
+ * Cleared on recovery: a successful `message_end` or an `auto_retry_end` with
418
+ * `success:true` resets it to null, so a recovered error never surfaces as a
419
+ * false failure. Evaluated once at session end in `runSession()`.
420
+ */
421
+ private terminalError: string | null = null;
364
422
 
365
423
  constructor(agentSession: AgentSession, config: ProviderSessionConfig, createdSymlink: boolean) {
366
424
  this.agentSession = agentSession;
@@ -376,6 +434,7 @@ export class PiMonoSession implements ProviderSession {
376
434
  type: "session_init",
377
435
  sessionId: this._sessionId,
378
436
  provider: "pi",
437
+ harnessVariant: "stock",
379
438
  ...(piVersion ? { harnessVariantMeta: { version: piVersion } } : {}),
380
439
  });
381
440
 
@@ -424,6 +483,25 @@ export class PiMonoSession implements ProviderSession {
424
483
  switch (event.type) {
425
484
  case "message_end": {
426
485
  // Pi emits message_end for user, assistant, and tool-result messages.
486
+ // An assistant turn that ended in `stopReason==='error'` is a failed
487
+ // turn — track it as the (so far) terminal error. This is the ONLY
488
+ // structured signal for NON-retryable failures (AWS auth: ExpiredToken
489
+ // / CredentialsProviderError), which never enter pi's retry loop.
490
+ const endMsg = event.message as {
491
+ role?: string;
492
+ stopReason?: string;
493
+ errorMessage?: string;
494
+ };
495
+ if (endMsg.role === "assistant") {
496
+ if (endMsg.stopReason === "error") {
497
+ // Candidate terminal failure. May still be cleared by a successful
498
+ // retry (auto_retry_end success / a later good message_end).
499
+ this.terminalError = endMsg.errorMessage ?? this.terminalError ?? "Unknown error";
500
+ break;
501
+ }
502
+ // A successful assistant turn means any prior error has recovered.
503
+ this.terminalError = null;
504
+ }
427
505
  // Only assistant text should be printed or used as fallback output.
428
506
  const text = extractPiAssistantText(event.message);
429
507
  if (text) {
@@ -517,12 +595,18 @@ export class PiMonoSession implements ProviderSession {
517
595
  result: event.result,
518
596
  });
519
597
  break;
520
- case "auto_retry_start":
521
- this.emit({
522
- type: "raw_stderr",
523
- content: `[pi-mono] Auto-retry attempt ${event.attempt}/${event.maxAttempts}: ${event.errorMessage}\n`,
524
- });
598
+ case "auto_retry_end": {
599
+ // Definitive terminal signal for the RETRYABLE error class
600
+ // (throttle / 5xx / timeout). pi-coding-agent emits success:false with
601
+ // `finalError` only after every retry attempt is exhausted; success:true
602
+ // means the turn recovered, so clear any tracked error.
603
+ if (event.success) {
604
+ this.terminalError = null;
605
+ } else {
606
+ this.terminalError = event.finalError ?? this.terminalError ?? "Unknown error";
607
+ }
525
608
  break;
609
+ }
526
610
  }
527
611
  }
528
612
 
@@ -540,6 +624,26 @@ export class PiMonoSession implements ProviderSession {
540
624
  const stats = this.agentSession.getSessionStats();
541
625
  const cost = this.buildCostData(stats);
542
626
 
627
+ // A structured terminal error from pi-coding-agent events is failure by
628
+ // definition (the agent already exhausted retries or hit a non-retryable
629
+ // error). Surface it so the session-chat red box fires and the task fails,
630
+ // exactly like sibling adapters. AWS errors get a categorized, actionable
631
+ // message; anything else surfaces its raw error text.
632
+ if (this.terminalError) {
633
+ const classification = classifyAwsSdkError(this.terminalError);
634
+ const message = classification?.message ?? this.terminalError;
635
+ const category = classification?.category;
636
+ this.emit({ type: "error", message, category });
637
+ return {
638
+ exitCode: 1,
639
+ sessionId: this._sessionId,
640
+ cost,
641
+ isError: true,
642
+ errorCategory: category,
643
+ failureReason: message,
644
+ };
645
+ }
646
+
543
647
  this.emit({
544
648
  type: "result",
545
649
  cost,
@@ -555,13 +659,26 @@ export class PiMonoSession implements ProviderSession {
555
659
  };
556
660
  } catch (err) {
557
661
  const errorMessage = err instanceof Error ? err.message : String(err);
662
+ // Defense-in-depth: AWS SDK failures surface as structured events (handled
663
+ // above in runSession), not thrown exceptions, so this catch is for genuine
664
+ // unexpected throws (MCP / transport / etc). Still classify in case an AWS
665
+ // signature ever reaches here, so the red box fires like sibling adapters.
666
+ const awsCatchError = classifyAwsSdkError(errorMessage);
667
+ if (awsCatchError) {
668
+ this.emit({
669
+ type: "error",
670
+ message: awsCatchError.message,
671
+ category: awsCatchError.category,
672
+ });
673
+ }
558
674
  this.emit({ type: "raw_stderr", content: `[pi-mono] Error: ${errorMessage}\n` });
559
675
 
560
676
  return {
561
677
  exitCode: 1,
562
678
  sessionId: this._sessionId,
563
679
  isError: true,
564
- failureReason: errorMessage,
680
+ errorCategory: awsCatchError?.category,
681
+ failureReason: awsCatchError?.message ?? errorMessage,
565
682
  };
566
683
  } finally {
567
684
  await this.logFileHandle.end();
@@ -188,8 +188,20 @@ export interface CredStatus {
188
188
  * pi/opencode predicates probe the filesystem for `~/.codex/auth.json`,
189
189
  * `~/.pi/agent/auth.json`, `~/.local/share/opencode/auth.json`. Tests inject
190
190
  * a fake `fs` + `homeDir` to exercise the file-vs-env branches deterministically.
191
+ *
192
+ * `bedrockProbe` is an injectable for the Bedrock SDK probe path in
193
+ * `checkPiMonoCredentials`. In production it is left undefined and the
194
+ * function dynamically imports `@aws-sdk/client-bedrock` to run a real
195
+ * `ListFoundationModels` call. Tests inject a stub to avoid hitting AWS.
191
196
  */
192
197
  export interface CredCheckOptions {
193
198
  homeDir?: string;
194
199
  fs?: { existsSync(p: string): boolean };
200
+ /**
201
+ * Injectable for Bedrock SDK credential probe. When provided, called instead
202
+ * of the real `@aws-sdk/client-bedrock` `ListFoundationModels` call.
203
+ * Should throw on auth/access failure (with an AWS SDK-shaped error message)
204
+ * or resolve on success.
205
+ */
206
+ bedrockProbe?: () => Promise<void>;
195
207
  }
@@ -3,7 +3,7 @@ import { CronExpressionParser } from "cron-parser";
3
3
  import { getDb, getDueScheduledTasks, getScheduledTaskById, updateScheduledTask } from "@/be/db";
4
4
  import { scheduleContextKey } from "@/tasks/context-key";
5
5
  import { createTaskWithSiblingAwareness } from "@/tasks/sibling-awareness";
6
- import type { ScheduledTask } from "@/types";
6
+ import type { AgentTask, ScheduledTask } from "@/types";
7
7
  import type { ExecutorRegistry } from "@/workflows/executors/registry";
8
8
  import { handleScheduleTrigger } from "@/workflows/triggers";
9
9
 
@@ -11,6 +11,24 @@ let schedulerInterval: ReturnType<typeof setInterval> | null = null;
11
11
  let isProcessing = false;
12
12
  let executorRegistry: ExecutorRegistry | null = null;
13
13
 
14
+ export function createStandaloneScheduleTask(
15
+ schedule: ScheduledTask,
16
+ extraTags: string[] = [],
17
+ ): AgentTask {
18
+ return createTaskWithSiblingAwareness(schedule.taskTemplate, {
19
+ creatorAgentId: schedule.createdByAgentId,
20
+ taskType: schedule.taskType,
21
+ tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, ...extraTags],
22
+ priority: schedule.priority,
23
+ agentId: schedule.targetAgentId,
24
+ model: schedule.model,
25
+ modelTier: schedule.modelTier,
26
+ scheduleId: schedule.id,
27
+ source: "schedule",
28
+ contextKey: scheduleContextKey({ scheduleId: schedule.id }),
29
+ });
30
+ }
31
+
14
32
  /**
15
33
  * Recover missed scheduled task runs from downtime.
16
34
  * Fires ONE catch-up run per schedule (not N missed runs).
@@ -45,17 +63,7 @@ async function recoverMissedSchedules(): Promise<void> {
45
63
 
46
64
  if (!triggeredWorkflows) {
47
65
  const tx = getDb().transaction(() => {
48
- createTaskWithSiblingAwareness(schedule.taskTemplate, {
49
- creatorAgentId: schedule.createdByAgentId,
50
- taskType: schedule.taskType,
51
- tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, "recovered"],
52
- priority: schedule.priority,
53
- agentId: schedule.targetAgentId,
54
- model: schedule.model,
55
- scheduleId: schedule.id,
56
- source: "schedule",
57
- contextKey: scheduleContextKey({ scheduleId: schedule.id }),
58
- });
66
+ createStandaloneScheduleTask(schedule, ["recovered"]);
59
67
  });
60
68
  tx();
61
69
  }
@@ -150,17 +158,7 @@ async function executeSchedule(schedule: ScheduledTask): Promise<void> {
150
158
  if (!triggeredWorkflows) {
151
159
  // No workflows linked — create standalone task (existing behavior)
152
160
  getDb().transaction(() => {
153
- createTaskWithSiblingAwareness(schedule.taskTemplate, {
154
- creatorAgentId: schedule.createdByAgentId,
155
- taskType: schedule.taskType,
156
- tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`],
157
- priority: schedule.priority,
158
- agentId: schedule.targetAgentId,
159
- model: schedule.model,
160
- scheduleId: schedule.id,
161
- source: "schedule",
162
- contextKey: scheduleContextKey({ scheduleId: schedule.id }),
163
- });
161
+ createStandaloneScheduleTask(schedule);
164
162
  })();
165
163
  }
166
164
 
@@ -341,17 +339,7 @@ export async function runScheduleNow(scheduleId: string): Promise<void> {
341
339
  if (!triggeredWorkflows) {
342
340
  // No workflows linked — create standalone task (existing behavior)
343
341
  getDb().transaction(() => {
344
- createTaskWithSiblingAwareness(schedule.taskTemplate, {
345
- creatorAgentId: schedule.createdByAgentId,
346
- taskType: schedule.taskType,
347
- tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, "manual-run"],
348
- priority: schedule.priority,
349
- agentId: schedule.targetAgentId,
350
- model: schedule.model,
351
- scheduleId: schedule.id,
352
- source: "schedule",
353
- contextKey: scheduleContextKey({ scheduleId: schedule.id }),
354
- });
342
+ createStandaloneScheduleTask(schedule, ["manual-run"]);
355
343
  })();
356
344
  }
357
345
 
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
3
  import pkg from "../package.json";
4
+ import { ModelTierSchema } from "./model-tiers";
4
5
  import {
5
6
  cancelTaskHandler,
6
7
  cancelTaskInputSchema,
@@ -28,9 +29,14 @@ const userSendTaskInputSchema = z.object({
28
29
  tags: z.array(z.string()).optional().describe("Tags for filtering (e.g., ['urgent'])."),
29
30
  priority: z.number().int().min(0).max(100).optional().describe("Priority 0-100 (default: 50)."),
30
31
  model: z
31
- .enum(["haiku", "sonnet", "opus", "fable"])
32
+ .string()
33
+ .trim()
34
+ .min(1)
32
35
  .optional()
33
- .describe("Model to use for this task ('haiku', 'sonnet', 'opus', or 'fable')."),
36
+ .describe("Concrete model override interpreted by the assignee's harness/provider."),
37
+ modelTier: ModelTierSchema.optional().describe(
38
+ "Portable model tier: 'smol', 'regular', 'smart', or 'ultra'. Resolved by the assignee's harness/provider.",
39
+ ),
34
40
  });
35
41
 
36
42
  export function createUserServer(user: User): McpServer {
@@ -15,6 +15,20 @@ import {
15
15
  // Re-export for backward compatibility
16
16
  export { markdownToSlack } from "./blocks";
17
17
 
18
+ export type SlackUpdateResult = "ok" | "not_found" | "failed";
19
+
20
+ function classifySlackUpdateError(error: unknown): SlackUpdateResult {
21
+ const errorCode = (error as { data?: { error?: string } } | undefined)?.data?.error;
22
+ if (
23
+ errorCode === "message_not_found" ||
24
+ errorCode === "channel_not_found" ||
25
+ errorCode === "thread_not_found"
26
+ ) {
27
+ return "not_found";
28
+ }
29
+ return "failed";
30
+ }
31
+
18
32
  const isDev = process.env.ENV === "development";
19
33
 
20
34
  /**
@@ -140,12 +154,12 @@ export async function updateProgressInPlace(
140
154
  task: AgentTask,
141
155
  progress: string,
142
156
  messageTs: string,
143
- ): Promise<boolean> {
157
+ ): Promise<SlackUpdateResult> {
144
158
  const app = getSlackApp();
145
- if (!app || !task.slackChannelId || !task.agentId) return false;
159
+ if (!app || !task.slackChannelId || !task.agentId) return "failed";
146
160
 
147
161
  const agent = getAgentById(task.agentId);
148
- if (!agent) return false;
162
+ if (!agent) return "failed";
149
163
 
150
164
  const blocks = buildProgressBlocks({ agentName: agent.name, taskId: task.id, progress });
151
165
 
@@ -157,10 +171,17 @@ export async function updateProgressInPlace(
157
171
  // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
158
172
  blocks: blocks as any,
159
173
  });
160
- return true;
174
+ return "ok";
161
175
  } catch (error) {
162
- console.error(`[Slack] Failed to update progress in-place:`, error);
163
- return false;
176
+ const result = classifySlackUpdateError(error);
177
+ if (result === "not_found") {
178
+ console.warn(
179
+ `[Slack] Progress message missing for task ${task.id} ts=${messageTs}; will repost`,
180
+ );
181
+ } else {
182
+ console.error(`[Slack] Failed to update progress in-place:`, error);
183
+ }
184
+ return result;
164
185
  }
165
186
  }
166
187
 
@@ -233,9 +254,9 @@ export async function updateTreeMessage(
233
254
  messageTs: string,
234
255
  blocks: unknown[],
235
256
  fallbackText: string,
236
- ): Promise<boolean> {
257
+ ): Promise<SlackUpdateResult> {
237
258
  const app = getSlackApp();
238
- if (!app) return false;
259
+ if (!app) return "failed";
239
260
 
240
261
  try {
241
262
  await app.client.chat.update({
@@ -245,10 +266,17 @@ export async function updateTreeMessage(
245
266
  // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
246
267
  blocks: blocks as any,
247
268
  });
248
- return true;
269
+ return "ok";
249
270
  } catch (error) {
250
- console.error(`[Slack] Failed to update tree message:`, error);
251
- return false;
271
+ const result = classifySlackUpdateError(error);
272
+ if (result === "not_found") {
273
+ console.warn(
274
+ `[Slack] Tree message missing for channel=${channelId} ts=${messageTs}; will repost`,
275
+ );
276
+ } else {
277
+ console.error(`[Slack] Failed to update tree message:`, error);
278
+ }
279
+ return result;
252
280
  }
253
281
  }
254
282