@app-vitals/agent-runtime 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +50 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
package/cron-handler.ts
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent cron request handler.
|
|
3
|
+
*
|
|
4
|
+
* Receives a cron payload, runs the prompt through Claude, and posts the
|
|
5
|
+
* result to Slack. Extracted as a pure function with injected deps for
|
|
6
|
+
* testability.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import type { WebClient } from "@slack/web-api";
|
|
13
|
+
import {
|
|
14
|
+
ClaudeRunError,
|
|
15
|
+
type ClaudeRunResult,
|
|
16
|
+
ClaudeTimeoutError,
|
|
17
|
+
type EarlySessionIdCallback,
|
|
18
|
+
type ModelUsage,
|
|
19
|
+
type ProgressCallback,
|
|
20
|
+
type TokenUsage,
|
|
21
|
+
} from "./claude.ts";
|
|
22
|
+
import { type Clock, SystemClock } from "./clock.ts";
|
|
23
|
+
import type { RunContextStamp } from "./context-stamp.ts";
|
|
24
|
+
import { markCronRunFailureReported } from "./cron-failure-reporter.ts";
|
|
25
|
+
import type {
|
|
26
|
+
ContextBaselineEntry,
|
|
27
|
+
CronRunReporter,
|
|
28
|
+
ModelBreakdownEntry,
|
|
29
|
+
SkillUsageEntry,
|
|
30
|
+
} from "./cron-run-reporter.ts";
|
|
31
|
+
import { markdownToSlack } from "./format.ts";
|
|
32
|
+
import { parseMarkers } from "./markers.ts";
|
|
33
|
+
import type { RunTelemetry } from "./run-telemetry.ts";
|
|
34
|
+
import { dispatchMarkers, type SynthesizeSpeechFn } from "./slack.ts";
|
|
35
|
+
import { buildSpawnEnv } from "./spawn-env.ts";
|
|
36
|
+
import type { VoiceConfig } from "./voice.ts";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Optional per-run measurement folded into the token payload: the stream
|
|
40
|
+
* telemetry (`run-telemetry.ts`) and the always-loaded-context stamp
|
|
41
|
+
* (`context-stamp.ts`). Both are additive — a caller that passes neither
|
|
42
|
+
* gets exactly the pre-existing payload shape.
|
|
43
|
+
*/
|
|
44
|
+
export interface TokenPayloadExtras {
|
|
45
|
+
telemetry?: RunTelemetry;
|
|
46
|
+
contextStamp?: RunContextStamp;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildTokenPayload(
|
|
50
|
+
usage: TokenUsage | undefined,
|
|
51
|
+
modelUsage: ModelUsage | undefined,
|
|
52
|
+
extras?: TokenPayloadExtras,
|
|
53
|
+
): {
|
|
54
|
+
inputTokens?: number;
|
|
55
|
+
outputTokens?: number;
|
|
56
|
+
cacheReadTokens?: number;
|
|
57
|
+
cacheCreationTokens?: number;
|
|
58
|
+
modelBreakdown?: ModelBreakdownEntry[];
|
|
59
|
+
contextBaseline?: ContextBaselineEntry;
|
|
60
|
+
turns?: number;
|
|
61
|
+
toolCalls?: number;
|
|
62
|
+
skillUsage?: SkillUsageEntry[];
|
|
63
|
+
contextFingerprint?: string;
|
|
64
|
+
pluginVersion?: string;
|
|
65
|
+
claudeCodeVersion?: string;
|
|
66
|
+
} {
|
|
67
|
+
// Build per-model breakdown when modelUsage has entries
|
|
68
|
+
let modelBreakdown: ModelBreakdownEntry[] | undefined;
|
|
69
|
+
if (modelUsage && Object.keys(modelUsage).length > 0) {
|
|
70
|
+
modelBreakdown = Object.entries(modelUsage).map(([model, mu]) => ({
|
|
71
|
+
model,
|
|
72
|
+
inputTokens: mu.inputTokens,
|
|
73
|
+
outputTokens: mu.outputTokens,
|
|
74
|
+
cacheReadTokens: mu.cacheReadInputTokens,
|
|
75
|
+
cacheCreationTokens: mu.cacheCreationInputTokens,
|
|
76
|
+
costUsd: mu.costUSD,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const telemetry = extras?.telemetry;
|
|
81
|
+
const stamp = extras?.contextStamp;
|
|
82
|
+
const firstTurn = telemetry?.firstTurn;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
inputTokens: usage?.input_tokens,
|
|
86
|
+
outputTokens: usage?.output_tokens,
|
|
87
|
+
cacheReadTokens: usage?.cache_read_input_tokens,
|
|
88
|
+
cacheCreationTokens: usage?.cache_creation_input_tokens,
|
|
89
|
+
...(modelBreakdown !== undefined && { modelBreakdown }),
|
|
90
|
+
...(firstTurn !== undefined && {
|
|
91
|
+
contextBaseline: {
|
|
92
|
+
model: firstTurn.model,
|
|
93
|
+
contextTokens: firstTurn.contextTokens,
|
|
94
|
+
inputTokens: firstTurn.inputTokens,
|
|
95
|
+
cacheCreationTokens: firstTurn.cacheCreationTokens,
|
|
96
|
+
cacheReadTokens: firstTurn.cacheReadTokens,
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
...(telemetry !== undefined && {
|
|
100
|
+
turns: telemetry.turns,
|
|
101
|
+
toolCalls: telemetry.toolCalls,
|
|
102
|
+
}),
|
|
103
|
+
...(telemetry !== undefined &&
|
|
104
|
+
telemetry.skillUsage.length > 0 && { skillUsage: telemetry.skillUsage }),
|
|
105
|
+
...(stamp !== undefined && {
|
|
106
|
+
contextFingerprint: stamp.contextFingerprint,
|
|
107
|
+
}),
|
|
108
|
+
...(stamp?.pluginVersion != null && { pluginVersion: stamp.pluginVersion }),
|
|
109
|
+
...(stamp?.claudeCodeVersion != null && {
|
|
110
|
+
claudeCodeVersion: stamp.claudeCodeVersion,
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The claude runner — sends a one-shot cron-dispatched message. The optional
|
|
117
|
+
* second `onProgress` param (CSU-1.1) is fired as each new assistant turn
|
|
118
|
+
* completes, with a fresh accumulated per-model usage snapshot — wired to
|
|
119
|
+
* cronRunReporter.recordProgress (debounced) inside handleCronRequest so
|
|
120
|
+
* token totals survive an agent-process kill mid-run, not just a clean
|
|
121
|
+
* completion. Mirrors loop-orchestrator.ts's runner signature (CSU-3.1).
|
|
122
|
+
*/
|
|
123
|
+
type ClaudeRunner = (
|
|
124
|
+
message: string,
|
|
125
|
+
onProgress?: ProgressCallback,
|
|
126
|
+
extraEnv?: Record<string, string>,
|
|
127
|
+
onEarlySessionId?: EarlySessionIdCallback,
|
|
128
|
+
) => Promise<ClaudeRunResult>;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Debounce window for onProgress → cronRunReporter.recordProgress pushes
|
|
132
|
+
* within a single dispatch. A chatty run can fire onProgress on every
|
|
133
|
+
* assistant turn; without this, each turn would PATCH the admin API,
|
|
134
|
+
* hammering it on long multi-turn runs. A push that fires less than this
|
|
135
|
+
* many ms after the last one that actually went through is skipped — the
|
|
136
|
+
* next progress snapshot (or the final completeRun) carries the up-to-date
|
|
137
|
+
* totals instead. Mirrors loop-orchestrator.ts's PROGRESS_PUSH_DEBOUNCE_MS
|
|
138
|
+
* (CSU-3.1).
|
|
139
|
+
*/
|
|
140
|
+
const PROGRESS_PUSH_DEBOUNCE_MS = 5000;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Format a cron-dispatched message with a `[Cron job: <jobId>]` tag and the
|
|
144
|
+
* current time. The tag is what a downstream time-tracking integration's
|
|
145
|
+
* session-merging logic keys off of to exclude unattended/automated cron
|
|
146
|
+
* work from a user's continuous work session view — so every runner() call
|
|
147
|
+
* originating from a cron dispatch (including shipwright-loop's per-phase
|
|
148
|
+
* dispatches) must be wrapped through this helper.
|
|
149
|
+
*/
|
|
150
|
+
export function formatCronMessage(jobId: string, prompt: string): string {
|
|
151
|
+
const now = new Date().toLocaleString("en-US", {
|
|
152
|
+
timeZone: "America/Los_Angeles",
|
|
153
|
+
});
|
|
154
|
+
return `[Cron job: ${jobId}] Current time: ${now}\n\n${prompt}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface CronRequest {
|
|
158
|
+
jobId: string;
|
|
159
|
+
prompt: string;
|
|
160
|
+
channel?: string;
|
|
161
|
+
user?: string;
|
|
162
|
+
silent?: boolean;
|
|
163
|
+
preCheck?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface CronHandlerDeps {
|
|
167
|
+
/**
|
|
168
|
+
* Slack client, or `undefined` on a chat-only agent (no Slack credentials).
|
|
169
|
+
* When absent, a job with a channel/user delivery target still runs and is
|
|
170
|
+
* recorded as completed — the delivery step is skipped, not failed.
|
|
171
|
+
*/
|
|
172
|
+
slack: WebClient | undefined;
|
|
173
|
+
runner: ClaudeRunner;
|
|
174
|
+
formatter?: (text: string) => string;
|
|
175
|
+
/** Called after a successful post so the caller can track the thread. */
|
|
176
|
+
onPost?: (channel: string, ts: string) => void;
|
|
177
|
+
/** Called after a successful post when a sessionId is available. */
|
|
178
|
+
onSession?: (
|
|
179
|
+
channel: string,
|
|
180
|
+
ts: string,
|
|
181
|
+
sessionId: string,
|
|
182
|
+
) => void | Promise<void>;
|
|
183
|
+
/** Optional speech synthesis — if absent, [speak:] markers are skipped. */
|
|
184
|
+
synthesizeSpeechFn?: SynthesizeSpeechFn;
|
|
185
|
+
voiceConfig?: VoiceConfig;
|
|
186
|
+
/** Workspace root — used to resolve relative preCheck scripts and root the run. */
|
|
187
|
+
workspace?: string;
|
|
188
|
+
/** Plugin cache dir — overridable for testing. */
|
|
189
|
+
pluginCacheDir?: string;
|
|
190
|
+
pluginManifestPath?: string;
|
|
191
|
+
alertsChannel?: string;
|
|
192
|
+
/** Reports cron run outcomes to the admin API (fire-and-forget). */
|
|
193
|
+
cronRunReporter?: CronRunReporter;
|
|
194
|
+
/** The agent's own ID — used to construct the reporter URL. */
|
|
195
|
+
agentId?: string;
|
|
196
|
+
/** Clock for deterministic time in tests. Defaults to SystemClock(). */
|
|
197
|
+
clock?: Clock;
|
|
198
|
+
/**
|
|
199
|
+
* Reads the always-loaded-context stamp (workspace CLAUDE.md + rules +
|
|
200
|
+
* plugin version fingerprint, see context-stamp.ts) once per run, so the
|
|
201
|
+
* run row can be grouped by what the model was given. Optional: when
|
|
202
|
+
* absent, runs are reported without a fingerprint.
|
|
203
|
+
*/
|
|
204
|
+
contextStamp?: () => RunContextStamp | null;
|
|
205
|
+
/**
|
|
206
|
+
* The default GitHub org (pinned installation, else lowest installation id),
|
|
207
|
+
* or null when unknown. Cron jobs carry no repo, so this is the owner hint
|
|
208
|
+
* (SHIPWRIGHT_GH_OWNER) their spawn gets (GOR-1.3).
|
|
209
|
+
*/
|
|
210
|
+
getDefaultOwner?: () => string | null;
|
|
211
|
+
/**
|
|
212
|
+
* Spawner used to run the preCheck script. Defaults to the real
|
|
213
|
+
* Bun.spawn — overridable in tests to inject a fake process instead of
|
|
214
|
+
* spawning ~15 real bun child processes per test run (CPS-1.1).
|
|
215
|
+
*/
|
|
216
|
+
spawner?: typeof Bun.spawn;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function handleCronRequest(
|
|
220
|
+
req: CronRequest,
|
|
221
|
+
deps: CronHandlerDeps,
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
const { jobId, channel, user, silent } = req;
|
|
224
|
+
let { prompt } = req;
|
|
225
|
+
const {
|
|
226
|
+
slack,
|
|
227
|
+
runner,
|
|
228
|
+
formatter = markdownToSlack,
|
|
229
|
+
onPost,
|
|
230
|
+
onSession,
|
|
231
|
+
synthesizeSpeechFn,
|
|
232
|
+
voiceConfig,
|
|
233
|
+
workspace,
|
|
234
|
+
pluginCacheDir,
|
|
235
|
+
pluginManifestPath = join(
|
|
236
|
+
homedir(),
|
|
237
|
+
".claude",
|
|
238
|
+
"plugins",
|
|
239
|
+
"installed_plugins.json",
|
|
240
|
+
),
|
|
241
|
+
cronRunReporter,
|
|
242
|
+
agentId: _agentId,
|
|
243
|
+
clock = SystemClock(),
|
|
244
|
+
contextStamp,
|
|
245
|
+
getDefaultOwner,
|
|
246
|
+
spawner = Bun.spawn,
|
|
247
|
+
} = deps;
|
|
248
|
+
|
|
249
|
+
const startedAt = clock.now();
|
|
250
|
+
|
|
251
|
+
if (!prompt) {
|
|
252
|
+
throw new ValidationError("missing required field: prompt");
|
|
253
|
+
}
|
|
254
|
+
if (!silent && !channel && !user) {
|
|
255
|
+
throw new ValidationError(
|
|
256
|
+
"missing delivery target: provide channel or user (or set silent=true)",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// CREATE run at start (before preCheck)
|
|
261
|
+
const runId = cronRunReporter
|
|
262
|
+
? await cronRunReporter.createRun(jobId, startedAt)
|
|
263
|
+
: null;
|
|
264
|
+
|
|
265
|
+
if (req.preCheck) {
|
|
266
|
+
const isRelative =
|
|
267
|
+
req.preCheck.startsWith("./") || req.preCheck.startsWith("../");
|
|
268
|
+
const isFilePath = isRelative || req.preCheck.startsWith("/");
|
|
269
|
+
|
|
270
|
+
let scriptPath: string | undefined;
|
|
271
|
+
|
|
272
|
+
if (isFilePath) {
|
|
273
|
+
if (isRelative && workspace === undefined) {
|
|
274
|
+
console.warn(
|
|
275
|
+
`[agent:cron] preCheck is a relative path but no workspace is set — skipping job "${jobId}"`,
|
|
276
|
+
);
|
|
277
|
+
await cronRunReporter?.skipRun(
|
|
278
|
+
jobId,
|
|
279
|
+
runId,
|
|
280
|
+
clock.now(),
|
|
281
|
+
"preCheck:not-found",
|
|
282
|
+
);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const candidate = isRelative
|
|
286
|
+
? resolve(workspace as string, req.preCheck)
|
|
287
|
+
: req.preCheck;
|
|
288
|
+
if (existsSync(candidate)) scriptPath = candidate;
|
|
289
|
+
} else {
|
|
290
|
+
const [plugin, script] = req.preCheck.split(":");
|
|
291
|
+
|
|
292
|
+
if (pluginCacheDir) {
|
|
293
|
+
const candidate = join(pluginCacheDir, plugin, "scripts", script);
|
|
294
|
+
if (existsSync(candidate)) scriptPath = candidate;
|
|
295
|
+
} else {
|
|
296
|
+
try {
|
|
297
|
+
const manifest = JSON.parse(
|
|
298
|
+
readFileSync(pluginManifestPath, "utf-8"),
|
|
299
|
+
) as {
|
|
300
|
+
version: number;
|
|
301
|
+
plugins: Record<string, Array<{ installPath?: string }>>;
|
|
302
|
+
};
|
|
303
|
+
const entry = Object.entries(manifest.plugins).find(([k]) =>
|
|
304
|
+
k.startsWith(`${plugin}@`),
|
|
305
|
+
);
|
|
306
|
+
const installPath = entry?.[1]?.[0]?.installPath;
|
|
307
|
+
if (installPath) {
|
|
308
|
+
const candidate = join(installPath, "scripts", script);
|
|
309
|
+
if (existsSync(candidate)) scriptPath = candidate;
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.warn(
|
|
313
|
+
`[agent:cron] failed to read installed_plugins.json: ${String(err)}`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (!scriptPath) {
|
|
320
|
+
console.warn(
|
|
321
|
+
`[agent:cron] preCheck script not found for "${req.preCheck}" — skipping job "${jobId}"`,
|
|
322
|
+
);
|
|
323
|
+
await cronRunReporter?.skipRun(
|
|
324
|
+
jobId,
|
|
325
|
+
runId,
|
|
326
|
+
clock.now(),
|
|
327
|
+
"preCheck:not-found",
|
|
328
|
+
);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Run the preCheck from the workspace, not the agent's cwd (/app). Plugin
|
|
333
|
+
// preChecks resolve state relative to process.cwd() — e.g. check-dev-task.ts
|
|
334
|
+
// queries the hosted task-store HTTP API (via check-helpers.ts) using
|
|
335
|
+
// SHIPWRIGHT_TASK_STORE_URL/TOKEN, and other prechecks read workspace-relative
|
|
336
|
+
// files like `state/agent-policy.md`. Without this the cwd roots at /app and
|
|
337
|
+
// workspace-relative reads fail. Mirrors the claude run, which is already
|
|
338
|
+
// rooted at the workspace.
|
|
339
|
+
const checkProc = spawner(["bun", scriptPath], {
|
|
340
|
+
...(workspace ? { cwd: workspace } : {}),
|
|
341
|
+
// env: process.env is required — Bun.spawn otherwise snapshots env at Bun
|
|
342
|
+
// startup and misses runtime mutations from config-sync (index.ts does
|
|
343
|
+
// applyEnvToProcess(bundle.env) every 60s). Without this, a
|
|
344
|
+
// preCheck reads boot-time credentials — e.g. a rotated
|
|
345
|
+
// SHIPWRIGHT_TASK_STORE_TOKEN — and fails (401) until the pod restarts.
|
|
346
|
+
// Mirrors setup.ts defaultExec, which documents the same gotcha.
|
|
347
|
+
env: process.env,
|
|
348
|
+
stdout: "pipe",
|
|
349
|
+
stderr: "pipe",
|
|
350
|
+
});
|
|
351
|
+
const [checkOutput, checkStderr] = await Promise.all([
|
|
352
|
+
new Response(checkProc.stdout).text(),
|
|
353
|
+
new Response(checkProc.stderr).text(),
|
|
354
|
+
]);
|
|
355
|
+
const checkExitCode = await checkProc.exited;
|
|
356
|
+
const output = checkOutput.trim();
|
|
357
|
+
|
|
358
|
+
if (checkExitCode >= 2) {
|
|
359
|
+
const detail = checkStderr.trim();
|
|
360
|
+
console.error(
|
|
361
|
+
`[agent:cron] preCheck for job "${jobId}" crashed (exit ${checkExitCode})${detail ? `: ${detail}` : ""} — session suppressed`,
|
|
362
|
+
);
|
|
363
|
+
if (deps.alertsChannel && deps.slack) {
|
|
364
|
+
try {
|
|
365
|
+
await deps.slack.chat.postMessage({
|
|
366
|
+
channel: deps.alertsChannel,
|
|
367
|
+
text: `[cron] preCheck for \`${jobId}\` crashed (exit ${checkExitCode}) — session suppressed${detail ? `\n\`\`\`\n${detail}\n\`\`\`` : ""}`,
|
|
368
|
+
});
|
|
369
|
+
} catch (alertErr) {
|
|
370
|
+
console.error(
|
|
371
|
+
"[agent:cron] failed to post preCheck alert:",
|
|
372
|
+
alertErr,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
await cronRunReporter?.skipRun(
|
|
377
|
+
jobId,
|
|
378
|
+
runId,
|
|
379
|
+
clock.now(),
|
|
380
|
+
"preCheck:crash",
|
|
381
|
+
{ error: detail || undefined },
|
|
382
|
+
);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (checkExitCode !== 0 || !output) {
|
|
387
|
+
console.log(
|
|
388
|
+
`[agent:cron] preCheck returned no work for job "${jobId}" — skipping tick`,
|
|
389
|
+
);
|
|
390
|
+
await cronRunReporter?.skipRun(
|
|
391
|
+
jobId,
|
|
392
|
+
runId,
|
|
393
|
+
clock.now(),
|
|
394
|
+
"preCheck:no-output",
|
|
395
|
+
);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
prompt = output;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const message = formatCronMessage(jobId, prompt);
|
|
403
|
+
|
|
404
|
+
console.log(`[agent:cron] running job "${jobId}"`);
|
|
405
|
+
|
|
406
|
+
// Resolve SLACK_CHANNEL_ID for the runner's extraEnv (STC-1.3), so
|
|
407
|
+
// slack-say inside the run knows which channel to address. A channel job
|
|
408
|
+
// uses its channel directly. A DM job resolves (and caches) the DM channel
|
|
409
|
+
// here, before the run, so the post-run delivery step below can reuse the
|
|
410
|
+
// same id instead of opening the DM twice. Resolution is best-effort — a
|
|
411
|
+
// missing channel id or a conversations.open rejection is logged and
|
|
412
|
+
// swallowed, never allowed to block the run. A silent job (job-level
|
|
413
|
+
// `silent`, not the [silent] response marker, which isn't known yet) never
|
|
414
|
+
// delivers, so DM resolution is skipped for it entirely. SLACK_THREAD_TS is
|
|
415
|
+
// never set here — cron runs have no parent thread to reply into.
|
|
416
|
+
let extraEnv: Record<string, string> | undefined;
|
|
417
|
+
let resolvedDmChannel: string | undefined;
|
|
418
|
+
if (channel) {
|
|
419
|
+
extraEnv = { SLACK_CHANNEL_ID: channel };
|
|
420
|
+
} else if (user && slack && !silent) {
|
|
421
|
+
try {
|
|
422
|
+
const dmResult = await slack.conversations.open({ users: user });
|
|
423
|
+
const dmChannel = dmResult.channel?.id;
|
|
424
|
+
if (dmChannel) {
|
|
425
|
+
resolvedDmChannel = dmChannel;
|
|
426
|
+
extraEnv = { SLACK_CHANNEL_ID: dmChannel };
|
|
427
|
+
} else {
|
|
428
|
+
console.warn(
|
|
429
|
+
`[agent:cron] job "${jobId}" could not resolve a DM channel id for user ${user} — running without SLACK_CHANNEL_ID`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
} catch (err) {
|
|
433
|
+
console.warn(
|
|
434
|
+
`[agent:cron] job "${jobId}" failed to open DM for user ${user}: ${String(err)} — running without SLACK_CHANNEL_ID`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// GOR-1.3: a cron has no repo, so its spawn carries the default org as the
|
|
440
|
+
// gh owner hint (merged with SLACK_CHANNEL_ID). Warns once if none resolves.
|
|
441
|
+
extraEnv = buildSpawnEnv({
|
|
442
|
+
kind: "cron",
|
|
443
|
+
id: jobId,
|
|
444
|
+
owner: getDefaultOwner?.(),
|
|
445
|
+
extraEnv,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// ── Runner scope ─────────────────────────────────────────────────────────
|
|
449
|
+
// Errors here record a genuine failure and re-throw so the caller sees them.
|
|
450
|
+
let usage: TokenUsage | undefined;
|
|
451
|
+
let modelUsage: ModelUsage | undefined;
|
|
452
|
+
let result: string;
|
|
453
|
+
let sessionId: string | undefined;
|
|
454
|
+
let telemetry: RunTelemetry | undefined;
|
|
455
|
+
// Stamp the always-loaded context once, before the run, so a concurrent
|
|
456
|
+
// workspace edit mid-run can't make the fingerprint disagree with what
|
|
457
|
+
// this run's first turn actually loaded. Fail-soft: a reader error just
|
|
458
|
+
// leaves the run unstamped.
|
|
459
|
+
let stamp: RunContextStamp | undefined;
|
|
460
|
+
try {
|
|
461
|
+
stamp = contextStamp?.() ?? undefined;
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.warn(
|
|
464
|
+
`[agent:cron] job "${jobId}" context stamp failed: ${String(err)} — running unstamped`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
const extras = (t: RunTelemetry | undefined): TokenPayloadExtras => ({
|
|
468
|
+
telemetry: t,
|
|
469
|
+
contextStamp: stamp,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Progress push (CSU-3.2): fired as each new assistant turn completes so
|
|
473
|
+
// token totals survive an agent-process OOM/deploy-kill mid-run, not just
|
|
474
|
+
// a clean completion. Debounced against PROGRESS_PUSH_DEBOUNCE_MS (via the
|
|
475
|
+
// injected clock, not Date.now(), to stay deterministic under FixedClock)
|
|
476
|
+
// to avoid hammering the admin API on a chatty multi-turn run.
|
|
477
|
+
// recordProgress is fire-and-forget per its own doc comment — a rejection
|
|
478
|
+
// must not crash the run, so it's caught and swallowed. Mirrors
|
|
479
|
+
// loop-orchestrator.ts's onProgress wiring (CSU-3.1).
|
|
480
|
+
let lastProgressPushAt: number | undefined;
|
|
481
|
+
const onProgress: ProgressCallback = (progressModelUsage) => {
|
|
482
|
+
const { modelBreakdown } = buildTokenPayload(undefined, progressModelUsage);
|
|
483
|
+
if (!modelBreakdown || modelBreakdown.length === 0) return;
|
|
484
|
+
|
|
485
|
+
const now = clock.now();
|
|
486
|
+
const nowMs = now.getTime();
|
|
487
|
+
if (
|
|
488
|
+
lastProgressPushAt !== undefined &&
|
|
489
|
+
nowMs - lastProgressPushAt < PROGRESS_PUSH_DEBOUNCE_MS
|
|
490
|
+
) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
lastProgressPushAt = nowMs;
|
|
494
|
+
|
|
495
|
+
cronRunReporter
|
|
496
|
+
?.recordProgress(jobId, runId, modelBreakdown, now)
|
|
497
|
+
.catch((err) => {
|
|
498
|
+
console.warn(
|
|
499
|
+
`[agent:cron] recordProgress failed for run ${runId}: ${String(err)} — swallowing`,
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// Early session-id push (CES-1.1): fired as soon as the CLI's leading
|
|
505
|
+
// system/init stream-json line arrives, well before the run finishes — so
|
|
506
|
+
// the cron-run log has a sessionId for manual inspection even if the run
|
|
507
|
+
// never reaches a terminal state (crash, kill, timeout). Fire-and-forget
|
|
508
|
+
// per recordSessionId's own doc comment — a rejection must not crash the
|
|
509
|
+
// run, so it's caught and swallowed. Mirrors loop-orchestrator.ts's
|
|
510
|
+
// onEarlySessionId wiring and this file's own onProgress/recordProgress
|
|
511
|
+
// pattern just above.
|
|
512
|
+
const onEarlySessionId: EarlySessionIdCallback = (sid) => {
|
|
513
|
+
cronRunReporter?.recordSessionId(jobId, runId, sid).catch((err) => {
|
|
514
|
+
console.warn(
|
|
515
|
+
`[agent:cron] recordSessionId failed for run ${runId}: ${String(err)} — swallowing`,
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
const runResult = await runner(
|
|
522
|
+
message,
|
|
523
|
+
onProgress,
|
|
524
|
+
extraEnv,
|
|
525
|
+
onEarlySessionId,
|
|
526
|
+
);
|
|
527
|
+
if (runResult.streamIncomplete) {
|
|
528
|
+
// Clean process exit, but the stream never emitted a terminal `result`
|
|
529
|
+
// event — treat this the same as a genuine failure rather than letting
|
|
530
|
+
// an empty response masquerade as a completed run (see CSU-1.1 review).
|
|
531
|
+
throw new ClaudeRunError(
|
|
532
|
+
"claude stream ended without a terminal result event",
|
|
533
|
+
undefined,
|
|
534
|
+
"stream incomplete — no terminal result event",
|
|
535
|
+
runResult.sessionId,
|
|
536
|
+
runResult.modelUsage,
|
|
537
|
+
runResult.telemetry,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
usage = runResult.usage;
|
|
541
|
+
modelUsage = runResult.modelUsage;
|
|
542
|
+
result = runResult.result;
|
|
543
|
+
sessionId = runResult.sessionId;
|
|
544
|
+
telemetry = runResult.telemetry;
|
|
545
|
+
} catch (err) {
|
|
546
|
+
// Partial-usage-on-failure (CSU-3.2): a ClaudeTimeoutError carries
|
|
547
|
+
// whatever per-model usage was accumulated before the process was
|
|
548
|
+
// killed — attach it as modelBreakdown so a timed-out run's tokens
|
|
549
|
+
// aren't dropped entirely (the direct fix for "unknown model" on
|
|
550
|
+
// timed-out cron runs). Scoped narrowly to ClaudeTimeoutError's
|
|
551
|
+
// `partialModelUsage` field, not ClaudeRunError's differently-named
|
|
552
|
+
// `modelUsage` field — any other error (including ClaudeRunError) falls
|
|
553
|
+
// back to { error } only, unchanged. Mirrors loop-orchestrator.ts's
|
|
554
|
+
// catch-path handling (CSU-3.1).
|
|
555
|
+
// Telemetry (first-turn baseline, counts, attribution) rides along on
|
|
556
|
+
// both error types' `partialTelemetry` — it's additive and never changes
|
|
557
|
+
// which error paths forward token totals.
|
|
558
|
+
const partialTelemetry =
|
|
559
|
+
err instanceof ClaudeTimeoutError || err instanceof ClaudeRunError
|
|
560
|
+
? err.partialTelemetry
|
|
561
|
+
: undefined;
|
|
562
|
+
const tokenPayload =
|
|
563
|
+
err instanceof ClaudeTimeoutError
|
|
564
|
+
? buildTokenPayload(
|
|
565
|
+
undefined,
|
|
566
|
+
err.partialModelUsage,
|
|
567
|
+
extras(partialTelemetry),
|
|
568
|
+
)
|
|
569
|
+
: partialTelemetry !== undefined
|
|
570
|
+
? buildTokenPayload(undefined, undefined, extras(partialTelemetry))
|
|
571
|
+
: undefined;
|
|
572
|
+
|
|
573
|
+
// sessionId (CSI-2.2): only ClaudeRunError/ClaudeTimeoutError (CSI-1.1)
|
|
574
|
+
// carry a sessionId field — a timed-out or failed run may still have
|
|
575
|
+
// captured one off the leading system/init stream-json line before the
|
|
576
|
+
// failure. Any other error type has no such field, so this is undefined.
|
|
577
|
+
const failureSessionId =
|
|
578
|
+
err instanceof ClaudeRunError || err instanceof ClaudeTimeoutError
|
|
579
|
+
? err.sessionId
|
|
580
|
+
: undefined;
|
|
581
|
+
|
|
582
|
+
await cronRunReporter?.completeRun(jobId, runId, clock.now(), "failed", {
|
|
583
|
+
error: err instanceof Error ? err.message : String(err),
|
|
584
|
+
...tokenPayload,
|
|
585
|
+
sessionId: failureSessionId,
|
|
586
|
+
});
|
|
587
|
+
markCronRunFailureReported(err);
|
|
588
|
+
throw err;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── Post-run scope ───────────────────────────────────────────────────────
|
|
592
|
+
// Slack delivery errors must NOT corrupt the run record with outcome='failed'
|
|
593
|
+
// when Claude completed successfully — they are logged and re-thrown, but the
|
|
594
|
+
// run is already recorded as 'completed' (with full token/cost data) before
|
|
595
|
+
// any Slack call is attempted.
|
|
596
|
+
const { cleaned, markers } = parseMarkers(result);
|
|
597
|
+
const isSilentMarker = markers.some((m) => m.type === "silent");
|
|
598
|
+
|
|
599
|
+
if (silent) {
|
|
600
|
+
console.log(`[agent:cron] job "${jobId}" completed (silent — no post)`);
|
|
601
|
+
await cronRunReporter?.completeRun(jobId, runId, clock.now(), "completed", {
|
|
602
|
+
...buildTokenPayload(usage, modelUsage, extras(telemetry)),
|
|
603
|
+
sessionId,
|
|
604
|
+
});
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const isDmOnly = !!user && !channel;
|
|
608
|
+
if (isSilentMarker && !isDmOnly) {
|
|
609
|
+
// [silent] marker suppresses channel posts (and channel-wins-over-user posts)
|
|
610
|
+
// DMs always get a reply — [silent] is ignored when routing to a DM
|
|
611
|
+
console.log(`[agent:cron] job "${jobId}" completed (silent — no post)`);
|
|
612
|
+
await cronRunReporter?.completeRun(jobId, runId, clock.now(), "completed", {
|
|
613
|
+
...buildTokenPayload(usage, modelUsage, extras(telemetry)),
|
|
614
|
+
sessionId,
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Chat-only agent: the job produced output but there is no Slack client to
|
|
620
|
+
// deliver it with. Record the run as completed — the work happened — and skip
|
|
621
|
+
// delivery rather than throwing invalid_auth on the first postMessage.
|
|
622
|
+
if (!slack) {
|
|
623
|
+
console.warn(
|
|
624
|
+
`[agent:cron] job "${jobId}" completed but Slack is not configured — delivery skipped (set SLACK_BOT_TOKEN + SLACK_APP_TOKEN, or mark the job silent)`,
|
|
625
|
+
);
|
|
626
|
+
await cronRunReporter?.completeRun(jobId, runId, clock.now(), "completed", {
|
|
627
|
+
...buildTokenPayload(usage, modelUsage, extras(telemetry)),
|
|
628
|
+
sessionId,
|
|
629
|
+
});
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const formatted = formatter(cleaned);
|
|
634
|
+
// Slack rejects chat.postMessage with `no_text` on blank text (e.g. output
|
|
635
|
+
// that was only [react:]/[upload:] markers). Skip the text post but still
|
|
636
|
+
// dispatch markers that don't need a posted message.
|
|
637
|
+
const hasText = formatted.trim() !== "";
|
|
638
|
+
if (!hasText) {
|
|
639
|
+
console.warn(
|
|
640
|
+
`[agent:cron] job "${jobId}" produced empty text after marker stripping — skipping text post`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (channel) {
|
|
645
|
+
if (user) {
|
|
646
|
+
console.warn(
|
|
647
|
+
`[agent:cron] job "${jobId}" has both channel and user — posting to channel`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Record completion before Slack delivery — a Slack error must not
|
|
652
|
+
// overwrite a successful run with outcome='failed'.
|
|
653
|
+
await cronRunReporter?.completeRun(jobId, runId, clock.now(), "completed", {
|
|
654
|
+
...buildTokenPayload(usage, modelUsage, extras(telemetry)),
|
|
655
|
+
sessionId,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// The run is already recorded as "completed" above — any error from here
|
|
659
|
+
// on (postMessage, dispatchMarkers) must be tagged before rethrowing so
|
|
660
|
+
// index.ts's outer catch doesn't create a duplicate "failed" row for a
|
|
661
|
+
// tick that already completed successfully.
|
|
662
|
+
try {
|
|
663
|
+
const postResult = hasText
|
|
664
|
+
? await slack.chat.postMessage({ channel, text: formatted })
|
|
665
|
+
: undefined;
|
|
666
|
+
if (postResult) {
|
|
667
|
+
console.log(`[agent:cron] job "${jobId}" posted to channel ${channel}`);
|
|
668
|
+
}
|
|
669
|
+
if (postResult?.ts) {
|
|
670
|
+
onPost?.(channel, postResult.ts);
|
|
671
|
+
if (onSession && sessionId)
|
|
672
|
+
await onSession(channel, postResult.ts, sessionId);
|
|
673
|
+
} else if (postResult) {
|
|
674
|
+
console.warn(
|
|
675
|
+
`[agent:cron] job "${jobId}" postMessage returned no ts — react markers will be skipped`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
await dispatchMarkers(markers, {
|
|
679
|
+
client: slack,
|
|
680
|
+
channel,
|
|
681
|
+
postedTs: postResult?.ts,
|
|
682
|
+
synthesizeSpeechFn,
|
|
683
|
+
voiceConfig,
|
|
684
|
+
postedText: formatted,
|
|
685
|
+
});
|
|
686
|
+
} catch (err) {
|
|
687
|
+
markCronRunFailureReported(err);
|
|
688
|
+
throw err;
|
|
689
|
+
}
|
|
690
|
+
} else if (user) {
|
|
691
|
+
// Record completion before any Slack calls — conversations.open can also
|
|
692
|
+
// fail (network error or null channel), and we must not leave the
|
|
693
|
+
// AgentCronRun row permanently open if it does.
|
|
694
|
+
await cronRunReporter?.completeRun(jobId, runId, clock.now(), "completed", {
|
|
695
|
+
...buildTokenPayload(usage, modelUsage, extras(telemetry)),
|
|
696
|
+
sessionId,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// The run is already recorded as "completed" above — any error from here
|
|
700
|
+
// on (conversations.open, postMessage, dispatchMarkers) must be tagged
|
|
701
|
+
// before rethrowing so index.ts's outer catch doesn't create a duplicate
|
|
702
|
+
// "failed" row for a tick that already completed successfully.
|
|
703
|
+
try {
|
|
704
|
+
// Reuse the DM channel resolved before the run (STC-1.3) so the DM is
|
|
705
|
+
// opened at most once per dispatch. If pre-resolution didn't happen or
|
|
706
|
+
// failed (silent job, no slack client, or conversations.open error),
|
|
707
|
+
// fall back to opening it here, same as before.
|
|
708
|
+
const dmChannel =
|
|
709
|
+
resolvedDmChannel ??
|
|
710
|
+
(await slack.conversations.open({ users: user })).channel?.id;
|
|
711
|
+
if (!dmChannel) {
|
|
712
|
+
throw new Error(`[agent:cron] could not open DM for user ${user}`);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const dmPostResult = hasText
|
|
716
|
+
? await slack.chat.postMessage({ channel: dmChannel, text: formatted })
|
|
717
|
+
: undefined;
|
|
718
|
+
if (dmPostResult) {
|
|
719
|
+
console.log(`[agent:cron] job "${jobId}" posted DM to user ${user}`);
|
|
720
|
+
}
|
|
721
|
+
if (dmPostResult?.ts) {
|
|
722
|
+
onPost?.(dmChannel, dmPostResult.ts);
|
|
723
|
+
if (onSession && sessionId)
|
|
724
|
+
await onSession(dmChannel, dmPostResult.ts, sessionId);
|
|
725
|
+
} else if (dmPostResult) {
|
|
726
|
+
console.warn(
|
|
727
|
+
`[agent:cron] job "${jobId}" DM postMessage returned no ts — react markers will be skipped`,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
await dispatchMarkers(markers, {
|
|
731
|
+
client: slack,
|
|
732
|
+
channel: dmChannel,
|
|
733
|
+
postedTs: dmPostResult?.ts,
|
|
734
|
+
synthesizeSpeechFn,
|
|
735
|
+
voiceConfig,
|
|
736
|
+
postedText: formatted,
|
|
737
|
+
});
|
|
738
|
+
} catch (err) {
|
|
739
|
+
markCronRunFailureReported(err);
|
|
740
|
+
throw err;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
export class ValidationError extends Error {
|
|
746
|
+
constructor(message: string) {
|
|
747
|
+
super(message);
|
|
748
|
+
this.name = "ValidationError";
|
|
749
|
+
}
|
|
750
|
+
}
|