discoclaw 0.7.0 → 0.8.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.
- package/.context/runtime.md +44 -0
- package/.env.example +2 -0
- package/.env.example.full +34 -4
- package/dist/cli/dashboard.js +3 -1
- package/dist/cli/dashboard.test.js +6 -0
- package/dist/config.js +1 -0
- package/dist/cron/cron-sync-coordinator.test.js +5 -4
- package/dist/cron/cron-sync.js +115 -6
- package/dist/cron/cron-sync.test.js +112 -1
- package/dist/cron/cron-tag-map-watcher.test.js +1 -1
- package/dist/cron/executor.js +8 -2
- package/dist/cron/forum-sync.js +93 -6
- package/dist/cron/forum-sync.test.js +9 -0
- package/dist/cron/run-stats.js +66 -1
- package/dist/cron/run-stats.test.js +7 -7
- package/dist/cron/scheduler.js +8 -0
- package/dist/dashboard/api/snapshot.test.js +3 -0
- package/dist/dashboard/page.js +38 -0
- package/dist/dashboard/page.test.js +6 -0
- package/dist/dashboard/server.js +45 -2
- package/dist/dashboard/server.test.js +136 -0
- package/dist/dashboard/shipped-entrypoint.test.js +2 -0
- package/dist/discord/action-categories.js +1 -0
- package/dist/discord/actions-config.js +34 -0
- package/dist/discord/actions-config.test.js +51 -5
- package/dist/discord/actions-crons.js +213 -121
- package/dist/discord/actions-crons.test.js +44 -16
- package/dist/discord/actions-forge.js +221 -12
- package/dist/discord/actions-forge.test.js +286 -3
- package/dist/discord/actions-loop.js +41 -4
- package/dist/discord/actions-loop.test.js +22 -0
- package/dist/discord/actions-plan.js +1 -1
- package/dist/discord/actions-spawn.js +14 -3
- package/dist/discord/actions.js +36 -2
- package/dist/discord/actions.test.js +125 -2
- package/dist/discord/deferred-runner.js +6 -1
- package/dist/discord/forge-auto-implement.js +9 -1
- package/dist/discord/forge-auto-implement.test.js +17 -0
- package/dist/discord/forge-commands.js +1531 -139
- package/dist/discord/forge-commands.test.js +1352 -25
- package/dist/discord/forge-plan-registry.js +202 -20
- package/dist/discord/forge-plan-registry.test.js +193 -1
- package/dist/discord/health-command.js +6 -0
- package/dist/discord/long-run-watchdog-notice.js +44 -0
- package/dist/discord/long-run-watchdog-notice.test.js +89 -0
- package/dist/discord/long-run-watchdog.js +50 -0
- package/dist/discord/long-run-watchdog.test.js +129 -0
- package/dist/discord/message-coordinator.followup-lifecycle.test.js +306 -0
- package/dist/discord/message-coordinator.js +250 -61
- package/dist/discord/message-coordinator.reaction-cleanup.test.js +1 -1
- package/dist/discord/message-coordinator.run-state.test.js +178 -0
- package/dist/discord/message-coordinator.test.js +191 -0
- package/dist/discord/models-command.js +1 -1
- package/dist/discord/models-command.test.js +3 -0
- package/dist/discord/output-common.js +54 -0
- package/dist/discord/pinned-message-utils.js +67 -0
- package/dist/discord/pinned-message-utils.test.js +35 -0
- package/dist/discord/plan-manager.js +26 -15
- package/dist/discord/plan-manager.test.js +32 -20
- package/dist/discord/prompt-common.js +22 -4
- package/dist/discord/prompt-common.test.js +58 -1
- package/dist/discord/reaction-handler.js +159 -37
- package/dist/discord/reaction-handler.test.js +257 -0
- package/dist/discord/run-state-guidance.js +12 -0
- package/dist/discord/run-state-guidance.test.js +24 -0
- package/dist/discord/status-channel.js +9 -0
- package/dist/discord/status-channel.test.js +32 -0
- package/dist/discord/thread-context.js +16 -5
- package/dist/discord/thread-context.test.js +19 -0
- package/dist/discord/user-errors.test.js +16 -0
- package/dist/discord-followup.test.js +63 -13
- package/dist/discord.prompt-context.test.js +35 -0
- package/dist/forge-phase.js +37 -0
- package/dist/health/config-doctor.js +202 -3
- package/dist/health/config-doctor.test.js +178 -2
- package/dist/health/startup-healing.js +52 -7
- package/dist/health/startup-healing.test.js +21 -15
- package/dist/index.js +56 -23
- package/dist/index.post-connect.js +2 -0
- package/dist/instructions/tracked-tools.js +133 -12
- package/dist/instructions/tracked-tools.test.js +116 -1
- package/dist/observability/metrics.js +2 -0
- package/dist/observability/metrics.test.js +6 -0
- package/dist/pipeline/engine.js +50 -3
- package/dist/pipeline/engine.test.js +52 -6
- package/dist/runtime/cli-adapter.js +90 -9
- package/dist/runtime/cli-adapter.test.js +146 -0
- package/dist/runtime/cli-shared.js +23 -0
- package/dist/runtime/cli-shared.test.js +66 -1
- package/dist/runtime/cli-strategy.js +90 -2
- package/dist/runtime/cli-strategy.test.js +54 -0
- package/dist/runtime/codex-app-server.js +1706 -0
- package/dist/runtime/codex-app-server.test.js +2220 -0
- package/dist/runtime/codex-cli.js +192 -4
- package/dist/runtime/codex-cli.test.js +630 -20
- package/dist/runtime/global-supervisor.js +14 -3
- package/dist/runtime/global-supervisor.test.js +28 -0
- package/dist/runtime/model-tiers.js +38 -0
- package/dist/runtime/model-tiers.test.js +28 -1
- package/dist/runtime/openai-auth.js +5 -0
- package/dist/runtime/openai-compat.js +57 -32
- package/dist/runtime/openai-compat.test.js +1 -1
- package/dist/runtime/openai-tool-exec.js +120 -68
- package/dist/runtime/openai-tool-exec.test.js +247 -8
- package/dist/runtime/runtime-failure.js +56 -1
- package/dist/runtime/runtime-failure.test.js +51 -1
- package/dist/runtime/strategies/codex-strategy.js +90 -28
- package/dist/runtime/strategies/codex-strategy.test.js +147 -0
- package/dist/runtime/tool-capabilities.js +95 -0
- package/dist/runtime/tool-capabilities.test.js +75 -1
- package/dist/runtime/tools/path-security.js +42 -17
- package/dist/runtime/tools/path-security.test.js +81 -0
- package/dist/tasks/task-sync-cli.js +1 -1
- package/dist/tasks/task-sync-cli.test.js +13 -0
- package/dist/tasks/task-sync-engine.test.js +81 -19
- package/dist/tasks/task-sync-phase-apply.js +12 -0
- package/dist/tasks/task-sync-reconcile.js +14 -5
- package/dist/workspace-bootstrap.js +59 -6
- package/docs/official-docs.md +1 -1
- package/package.json +2 -1
- package/templates/instructions/SYSTEM_DEFAULTS.md +19 -0
package/.context/runtime.md
CHANGED
|
@@ -77,6 +77,50 @@ Shutdown: `killAllSubprocesses()` from `cli-adapter.ts` kills all tracked subpro
|
|
|
77
77
|
- `CLAUDE_OUTPUT_FORMAT=stream-json` (preferred; DiscoClaw parses JSONL and streams text)
|
|
78
78
|
- `CLAUDE_OUTPUT_FORMAT=text` (fallback if your local CLI doesn't support stream-json)
|
|
79
79
|
|
|
80
|
+
## Codex CLI Runtime
|
|
81
|
+
|
|
82
|
+
- Adapter: `src/runtime/codex-cli.ts` (thin wrapper around `cli-adapter.ts` + `strategies/codex-strategy.ts`)
|
|
83
|
+
- Default transport:
|
|
84
|
+
- The default Codex path is `codex exec` / `codex exec resume`.
|
|
85
|
+
- DiscoClaw currently prefers the CLI route for reliability, especially in forge flows.
|
|
86
|
+
- Optional native transport:
|
|
87
|
+
- When explicitly enabled, DiscoClaw can use the Codex app-server websocket for `thread/start`, `turn/start`, streaming output, `turn/steer`, and `turn/interrupt`.
|
|
88
|
+
- The native path still uses Codex's local app-server auth/session state, so no public API key is required.
|
|
89
|
+
- Activation and fallback:
|
|
90
|
+
- Native invoke is opt-in via `CODEX_APP_SERVER_NATIVE=1`.
|
|
91
|
+
- Native invoke also requires `CODEX_APP_SERVER_URL` to point at a reachable websocket endpoint.
|
|
92
|
+
- If the native flag is off, the URL is unset, or the turn hits an images / non-default `cwd` bypass gate, DiscoClaw uses the `codex exec` / `codex exec resume` transport instead.
|
|
93
|
+
- If native invoke is selected but the websocket connection cannot be established, DiscoClaw falls back to `codex exec` for that turn rather than failing closed.
|
|
94
|
+
- Env vars:
|
|
95
|
+
| Var | Default | Purpose |
|
|
96
|
+
|-----|---------|---------|
|
|
97
|
+
| `CODEX_APP_SERVER_NATIVE` | `0` | Enables the app-server-native invoke path for eligible turns. |
|
|
98
|
+
| `CODEX_APP_SERVER_URL` | *(unset)* | Codex app-server websocket URL (for example `ws://127.0.0.1:4321`) used by the native path. |
|
|
99
|
+
- Native bypass gates:
|
|
100
|
+
- **Images:** turns with `images` bypass native invoke and stay on `codex exec`, because image parity is not guaranteed on the app-server path.
|
|
101
|
+
- **Non-default `cwd`:** turns whose `cwd` differs from the process working directory bypass native invoke and stay on `codex exec`, which is the only path that can shape the subprocess working directory per turn.
|
|
102
|
+
- **`addDirs`:** native invoke passes extra readable roots through to the app-server sandbox as additional `readableRoots`, so standard Discord turns can still stay on the websocket path.
|
|
103
|
+
- **Connection failure:** if the runtime cannot connect/initialize against the app-server websocket, it immediately drops back to `codex exec` for that invocation.
|
|
104
|
+
- Capability declaration:
|
|
105
|
+
- When native invoke is enabled and the app-server is configured, the runtime adds `mid_turn_steering` and exposes `RuntimeAdapter.steer()` + `RuntimeAdapter.interrupt()`.
|
|
106
|
+
- When native invoke is inactive, the Codex adapter behaves like the legacy CLI runtime: no control methods and no extra capability.
|
|
107
|
+
- Lifecycle and streaming:
|
|
108
|
+
- For native turns, the runtime creates or reuses a thread with `thread/start`, starts the turn with `turn/start`, and tracks the active turn directly from the app-server response/notifications.
|
|
109
|
+
- Streaming reply text comes from agent-message delta/completed notifications; tool progress comes from item start/completion notifications; terminal turn notifications clear the active turn and finish the stream.
|
|
110
|
+
- Because the runtime receives the live `turnId` from the app-server itself, steering and interrupt can target the active turn reliably instead of depending on `codex exec --json` to surface it mid-turn.
|
|
111
|
+
- Session semantics:
|
|
112
|
+
- Reusing the same `sessionKey` reuses the same native `threadId`, so repeated turns continue the same Codex thread.
|
|
113
|
+
- Omitting `sessionKey` creates an ephemeral native thread for that invocation only.
|
|
114
|
+
- `disableSessions` strips `sessionKey` before dispatch, so native turns become ephemeral and never reuse a prior thread.
|
|
115
|
+
- Steering / interrupt semantics:
|
|
116
|
+
- `steer(sessionKey, message)` is best-effort and returns `false` instead of throwing when there is no tracked active turn or the app-server request fails.
|
|
117
|
+
- Successful steering sends `turn/steer` with `threadId` plus `expectedTurnId`; if the server returns a replacement `turnId`, the runtime updates its active-turn pointer.
|
|
118
|
+
- `interrupt(sessionKey)` is also best-effort and sends `turn/interrupt` with the active `threadId` + `turnId`, then clears local active-turn state.
|
|
119
|
+
- Tradeoffs vs `codex exec`:
|
|
120
|
+
- Native invoke is useful when live control matters: it owns thread/turn lifecycle directly, exposes the active `turnId`, and supports steer/interrupt semantics.
|
|
121
|
+
- `codex exec` remains the default and preferred path for general reliability, plus any turns that need unsupported invocation shapes or when the app-server is unavailable.
|
|
122
|
+
- Keeping both paths preserves local Codex auth and session behavior without forcing the app-server path on day-to-day usage.
|
|
123
|
+
|
|
80
124
|
## Gemini CLI Runtime
|
|
81
125
|
|
|
82
126
|
- Adapter: `src/runtime/gemini-cli.ts` (thin wrapper around `cli-adapter.ts` + `strategies/gemini-strategy.ts`)
|
package/.env.example
CHANGED
|
@@ -91,6 +91,8 @@ DISCORD_GUILD_ID=
|
|
|
91
91
|
#DISCOCLAW_CODEX_ITEM_TYPE_DEBUG=1
|
|
92
92
|
# Log each Discord preview line decision (allowed/suppressed + rendered line) to journald:
|
|
93
93
|
#DISCOCLAW_DEBUG_STREAM_PREVIEW_LINES=1
|
|
94
|
+
# Discord action auto-follow-up stall timeout (ms). Controls when pending follow-up turns are treated as stalled.
|
|
95
|
+
#DISCOCLAW_ACTION_FOLLOWUP_TIMEOUT_MS=30000
|
|
94
96
|
|
|
95
97
|
# [DEPRECATED] Model configuration has moved to models.json (managed via !models commands).
|
|
96
98
|
# RUNTIME_MODEL is still read as a fallback when models.json is missing, but new deployments
|
package/.env.example.full
CHANGED
|
@@ -437,6 +437,8 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
|
|
|
437
437
|
#DISCOCLAW_MAX_CONCURRENT_INVOCATIONS=3
|
|
438
438
|
# Max depth for chained action follow-ups (e.g. defer → action → response). 0 = disabled.
|
|
439
439
|
#DISCOCLAW_ACTION_FOLLOWUP_DEPTH=3
|
|
440
|
+
# Timeout (ms) before a pending Discord action auto-follow-up is marked stalled. Default: 30000.
|
|
441
|
+
#DISCOCLAW_ACTION_FOLLOWUP_TIMEOUT_MS=30000
|
|
440
442
|
# Timeout for runtime invocations (ms).
|
|
441
443
|
#RUNTIME_TIMEOUT_MS=1800000
|
|
442
444
|
# Global runtime supervisor wrapper (off by default; preserves legacy behavior).
|
|
@@ -524,9 +526,13 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
|
|
|
524
526
|
# ----------------------------------------------------------
|
|
525
527
|
# Route the forge drafter to a non-Claude runtime.
|
|
526
528
|
# Valid values: "codex" (Codex CLI), "openai" (OpenAI-compatible HTTP API), "openrouter" (OpenRouter).
|
|
529
|
+
# Note: forge currently forces Codex phases onto codex exec by default instead of
|
|
530
|
+
# the native app-server path.
|
|
527
531
|
#FORGE_DRAFTER_RUNTIME=
|
|
528
532
|
# Route the forge auditor to a non-Claude runtime.
|
|
529
533
|
# Valid values: "codex" (Codex CLI), "openai" (OpenAI-compatible HTTP API), "openrouter" (OpenRouter).
|
|
534
|
+
# Note: forge currently forces Codex phases onto codex exec by default instead of
|
|
535
|
+
# the native app-server path.
|
|
530
536
|
#FORGE_AUDITOR_RUNTIME=
|
|
531
537
|
|
|
532
538
|
# --- Codex CLI adapter ---
|
|
@@ -534,6 +540,20 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
|
|
|
534
540
|
#CODEX_BIN=codex
|
|
535
541
|
# Optional: isolate Codex state/sessions from ~/.codex (helps avoid stale rollout DB issues).
|
|
536
542
|
#CODEX_HOME=/absolute/path/to/.codex-home-discoclaw
|
|
543
|
+
# Optional: Codex app-server websocket URL for the native app-server invoke path.
|
|
544
|
+
# When combined with CODEX_APP_SERVER_NATIVE=1, DiscoClaw uses the websocket for
|
|
545
|
+
# thread/turn lifecycle, streaming output, steering, and interrupts. Unset keeps
|
|
546
|
+
# the native path dormant.
|
|
547
|
+
#CODEX_APP_SERVER_URL=ws://127.0.0.1:4321
|
|
548
|
+
# Enable the optional native Codex app-server transport.
|
|
549
|
+
# Set to 1 to allow eligible Codex turns to use the websocket path. The CLI route
|
|
550
|
+
# remains the recommended default for normal ops because it has been more reliable.
|
|
551
|
+
# Image turns, non-default cwd turns, and websocket bootstrap failures fall back
|
|
552
|
+
# to codex exec automatically. Forge currently disables the native app-server
|
|
553
|
+
# route for Codex phases by default and uses codex exec instead; CODEX_APP_SERVER_NATIVE=1
|
|
554
|
+
# still applies to other eligible Codex turns. Extra readable roots from addDirs
|
|
555
|
+
# are passed through to the app-server sandbox.
|
|
556
|
+
#CODEX_APP_SERVER_NATIVE=0
|
|
537
557
|
# Default model for the Codex CLI adapter. Used when FORGE_AUDITOR_MODEL is not set.
|
|
538
558
|
#CODEX_MODEL=gpt-5.4
|
|
539
559
|
# WARNING: disables Codex approval prompts and sandbox protections (full-access mode).
|
|
@@ -593,8 +613,17 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
|
|
|
593
613
|
# ----------------------------------------------------------
|
|
594
614
|
# Image generation
|
|
595
615
|
# ----------------------------------------------------------
|
|
596
|
-
#
|
|
597
|
-
#
|
|
616
|
+
# Discoverable by default on the normal user-facing help/manual surfaces:
|
|
617
|
+
# `!models`, `!models help`, and the normal manual message/follow-up action path
|
|
618
|
+
# can advertise `imagegen` before setup is complete.
|
|
619
|
+
# `!models` shows `imagegen` as setup-required when unconfigured; `!models help`
|
|
620
|
+
# points operators back to env setup because `!models set` does not configure imagegen.
|
|
621
|
+
# Master switch for actual image generation execution (default: off).
|
|
622
|
+
# When set to 1 and a provider key is configured, the AI can generate images via
|
|
623
|
+
# action blocks using OpenAI or Gemini. When left at 0, normal manual/follow-up
|
|
624
|
+
# invocations return a setup walkthrough instead of generating an image.
|
|
625
|
+
# Reaction/deferred advertisement rules remain on their existing flag-driven contract,
|
|
626
|
+
# and loop execution does not use the manual setup walkthrough path.
|
|
598
627
|
DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=0
|
|
599
628
|
# API key for Gemini image generation (Imagen and native Gemini models).
|
|
600
629
|
# Leave unset to use OpenAI only.
|
|
@@ -606,8 +635,9 @@ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=0
|
|
|
606
635
|
# Gemini native models (text+image in one call): gemini-3.1-flash-image-preview, gemini-3-pro-image-preview
|
|
607
636
|
#IMAGEGEN_DEFAULT_MODEL=
|
|
608
637
|
# Note: OpenAI image generation reuses OPENAI_API_KEY (documented above in the
|
|
609
|
-
# OpenAI-compatible HTTP adapter section).
|
|
610
|
-
# at least one of OPENAI_API_KEY or
|
|
638
|
+
# OpenAI-compatible HTTP adapter section). Actual generation stays disabled until
|
|
639
|
+
# DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1 and at least one of OPENAI_API_KEY or
|
|
640
|
+
# IMAGEGEN_GEMINI_API_KEY is set.
|
|
611
641
|
|
|
612
642
|
# ----------------------------------------------------------
|
|
613
643
|
# Voice (STT/TTS) — join voice channels, listen and respond
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -7,7 +7,7 @@ import { config as loadDotenv } from 'dotenv';
|
|
|
7
7
|
import { getLocalVersion, isNpmManaged } from '../npm-managed.js';
|
|
8
8
|
import { isModelTier, listKnownModelValues } from '../runtime/model-tiers.js';
|
|
9
9
|
import { getGitHash } from '../version.js';
|
|
10
|
-
import { applyFixes, inspect, KNOWN_RUNTIMES, loadDoctorContext } from '../health/config-doctor.js';
|
|
10
|
+
import { applyFixes, inspect, KNOWN_RUNTIMES, loadDoctorContext, updateEnvKey } from '../health/config-doctor.js';
|
|
11
11
|
import { detectMcpServers, validateMcpEnvInterpolation, validateMcpServerEnv, validateMcpServerNames, } from '../mcp-detect.js';
|
|
12
12
|
import { DEFAULTS as MODEL_DEFAULTS, saveModelConfig } from '../model-config.js';
|
|
13
13
|
import { saveOverrides } from '../runtime-overrides.js';
|
|
@@ -59,6 +59,7 @@ function createDefaultDeps() {
|
|
|
59
59
|
loadDoctorContext,
|
|
60
60
|
saveModelConfig,
|
|
61
61
|
saveOverrides,
|
|
62
|
+
updateEnvKey,
|
|
62
63
|
runCommand(cmd, args) {
|
|
63
64
|
return new Promise((resolve) => {
|
|
64
65
|
execFile(cmd, args, { timeout: 15_000 }, (err, stdout, stderr) => {
|
|
@@ -311,6 +312,7 @@ export async function collectDashboardSnapshot(opts = {}, deps = createDefaultDe
|
|
|
311
312
|
},
|
|
312
313
|
mcpStatus: toBootReportMcpStatus(mcpDetectResult),
|
|
313
314
|
mcpWarnings,
|
|
315
|
+
primaryRuntime: normalizeRuntimeName(ctx.env.PRIMARY_RUNTIME) ?? 'claude',
|
|
314
316
|
};
|
|
315
317
|
}
|
|
316
318
|
export function renderDashboard(snapshot, detail = '') {
|
|
@@ -22,6 +22,7 @@ function makeConfigPaths(cwd, dataDir = path.join(cwd, 'data')) {
|
|
|
22
22
|
function makeDoctorContext(overrides = {}) {
|
|
23
23
|
return {
|
|
24
24
|
cwd: '/repo',
|
|
25
|
+
workspaceCwd: '/repo/workspace',
|
|
25
26
|
installMode: 'source',
|
|
26
27
|
env: {
|
|
27
28
|
DISCOCLAW_SERVICE_NAME: 'discoclaw-beta',
|
|
@@ -103,6 +104,7 @@ function makeDashboardDeps(overrides = {}) {
|
|
|
103
104
|
loadDoctorContext: vi.fn(async () => makeDoctorContext()),
|
|
104
105
|
saveModelConfig: vi.fn(async () => undefined),
|
|
105
106
|
saveOverrides: vi.fn(async () => undefined),
|
|
107
|
+
updateEnvKey: vi.fn(async () => undefined),
|
|
106
108
|
runCommand: vi.fn(async () => ({
|
|
107
109
|
stdout: 'Active: active (running)\n',
|
|
108
110
|
stderr: '',
|
|
@@ -148,6 +150,7 @@ function makeSnapshot(overrides = {}) {
|
|
|
148
150
|
},
|
|
149
151
|
mcpStatus: { status: 'missing' },
|
|
150
152
|
mcpWarnings: 0,
|
|
153
|
+
primaryRuntime: 'claude',
|
|
151
154
|
...overrides,
|
|
152
155
|
};
|
|
153
156
|
}
|
|
@@ -270,6 +273,7 @@ describe('collectDashboardSnapshot', () => {
|
|
|
270
273
|
loadDoctorContext: vi.fn(async () => ctx),
|
|
271
274
|
saveModelConfig: vi.fn(async () => undefined),
|
|
272
275
|
saveOverrides: vi.fn(async () => undefined),
|
|
276
|
+
updateEnvKey: vi.fn(async () => undefined),
|
|
273
277
|
runCommand: vi.fn(async () => ({
|
|
274
278
|
stdout: ' Active: active (running) since today\n',
|
|
275
279
|
stderr: '',
|
|
@@ -287,6 +291,7 @@ describe('collectDashboardSnapshot', () => {
|
|
|
287
291
|
expect(snapshot.doctorSummary).toBe('1 findings (errors=1, warnings=0, info=0)');
|
|
288
292
|
expect(snapshot.version).toBe('1.2.3');
|
|
289
293
|
expect(snapshot.gitHash).toBe('abc1234');
|
|
294
|
+
expect(snapshot.primaryRuntime).toBe('claude');
|
|
290
295
|
expect(snapshot.roles).toEqual([
|
|
291
296
|
'chat',
|
|
292
297
|
'plan-run',
|
|
@@ -328,6 +333,7 @@ describe('collectDashboardSnapshot', () => {
|
|
|
328
333
|
loadDoctorContext,
|
|
329
334
|
saveModelConfig: vi.fn(async () => undefined),
|
|
330
335
|
saveOverrides: vi.fn(async () => undefined),
|
|
336
|
+
updateEnvKey: vi.fn(async () => undefined),
|
|
331
337
|
runCommand: vi.fn(async () => ({
|
|
332
338
|
stdout: ' Active: active (running) since today\n',
|
|
333
339
|
stderr: '',
|
package/dist/config.js
CHANGED
|
@@ -481,6 +481,7 @@ export function parseConfig(env) {
|
|
|
481
481
|
forgeAutoImplement: parseBoolean(env, 'FORGE_AUTO_IMPLEMENT', true),
|
|
482
482
|
completionNotifyEnabled: parseBoolean(env, 'DISCOCLAW_COMPLETION_NOTIFY', true),
|
|
483
483
|
completionNotifyThresholdMs: parseNonNegativeInt(env, 'DISCOCLAW_COMPLETION_NOTIFY_THRESHOLD_MS', 30000),
|
|
484
|
+
actionFollowupTimeoutMs: parseNonNegativeInt(env, 'DISCOCLAW_ACTION_FOLLOWUP_TIMEOUT_MS', 30000),
|
|
484
485
|
openaiApiKey,
|
|
485
486
|
openaiBaseUrl,
|
|
486
487
|
openaiModel,
|
|
@@ -11,6 +11,7 @@ vi.mock('./cron-sync.js', () => ({
|
|
|
11
11
|
statusMessagesUpdated: 1,
|
|
12
12
|
promptMessagesCreated: 0,
|
|
13
13
|
orphansDetected: 0,
|
|
14
|
+
projectionsRepaired: 0,
|
|
14
15
|
})),
|
|
15
16
|
}));
|
|
16
17
|
import { reloadCronTagMapInPlace } from './tag-map.js';
|
|
@@ -41,7 +42,7 @@ describe('CronSyncCoordinator', () => {
|
|
|
41
42
|
vi.resetAllMocks();
|
|
42
43
|
mockReload.mockResolvedValue(2);
|
|
43
44
|
mockRunCronSync.mockResolvedValue({
|
|
44
|
-
tagsApplied: 1, namesUpdated: 0, statusMessagesUpdated: 1, promptMessagesCreated: 0, orphansDetected: 0,
|
|
45
|
+
tagsApplied: 1, namesUpdated: 0, statusMessagesUpdated: 1, promptMessagesCreated: 0, orphansDetected: 0, projectionsRepaired: 0,
|
|
45
46
|
});
|
|
46
47
|
});
|
|
47
48
|
it('reloads tag map before sync when tagMapPath is set', async () => {
|
|
@@ -70,7 +71,7 @@ describe('CronSyncCoordinator', () => {
|
|
|
70
71
|
it('coalesced concurrent sync returns null', async () => {
|
|
71
72
|
let resolveSync;
|
|
72
73
|
mockRunCronSync.mockImplementation(() => new Promise((resolve) => {
|
|
73
|
-
resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 });
|
|
74
|
+
resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0, projectionsRepaired: 0 });
|
|
74
75
|
}));
|
|
75
76
|
const coordinator = new CronSyncCoordinator(makeOpts());
|
|
76
77
|
const first = coordinator.sync();
|
|
@@ -86,10 +87,10 @@ describe('CronSyncCoordinator', () => {
|
|
|
86
87
|
mockRunCronSync.mockImplementation(() => new Promise((resolve) => {
|
|
87
88
|
callCount++;
|
|
88
89
|
if (callCount === 1) {
|
|
89
|
-
resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 });
|
|
90
|
+
resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0, projectionsRepaired: 0 });
|
|
90
91
|
}
|
|
91
92
|
else {
|
|
92
|
-
resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 });
|
|
93
|
+
resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0, projectionsRepaired: 0 });
|
|
93
94
|
}
|
|
94
95
|
}));
|
|
95
96
|
const coordinator = new CronSyncCoordinator(makeOpts());
|
package/dist/cron/cron-sync.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EmbedBuilder } from 'discord.js';
|
|
2
|
-
import { CADENCE_TAGS } from './run-stats.js';
|
|
2
|
+
import { CADENCE_TAGS, computeDefinitionHash } from './run-stats.js';
|
|
3
3
|
import { detectCadence } from './cadence.js';
|
|
4
4
|
import { autoTagCron, classifyCronModel } from './auto-tag.js';
|
|
5
5
|
import { buildCronThreadName, ensureStatusMessage, resolveForumChannel } from './discord-sync.js';
|
|
@@ -25,7 +25,7 @@ export async function runCronSync(opts) {
|
|
|
25
25
|
const forum = await resolveForumChannel(client, forumId);
|
|
26
26
|
if (!forum) {
|
|
27
27
|
log?.warn({ forumId }, 'cron-sync: forum not found');
|
|
28
|
-
return { tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 };
|
|
28
|
+
return { tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0, projectionsRepaired: 0 };
|
|
29
29
|
}
|
|
30
30
|
const tagMap = opts.tagMap;
|
|
31
31
|
const purposeTags = purposeTagNames(tagMap);
|
|
@@ -34,6 +34,7 @@ export async function runCronSync(opts) {
|
|
|
34
34
|
let statusMessagesUpdated = 0;
|
|
35
35
|
let promptMessagesCreated = 0;
|
|
36
36
|
let orphansDetected = 0;
|
|
37
|
+
let projectionsRepaired = 0;
|
|
37
38
|
const asEditableCronThread = (value) => {
|
|
38
39
|
if (!value || typeof value !== 'object')
|
|
39
40
|
return null;
|
|
@@ -46,13 +47,15 @@ export async function runCronSync(opts) {
|
|
|
46
47
|
return null;
|
|
47
48
|
}
|
|
48
49
|
const appliedTags = Array.isArray(t.appliedTags) ? t.appliedTags.filter((id) => typeof id === 'string') : undefined;
|
|
50
|
+
const sourceThread = value;
|
|
49
51
|
return {
|
|
50
52
|
id: t.id,
|
|
51
53
|
parentId: t.parentId,
|
|
52
54
|
name: t.name,
|
|
53
55
|
appliedTags,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
// Discord.js thread mutators read `this.client.rest`, so keep the live thread instance as `this`.
|
|
57
|
+
edit: t.edit.bind(sourceThread),
|
|
58
|
+
setName: t.setName.bind(sourceThread),
|
|
56
59
|
};
|
|
57
60
|
};
|
|
58
61
|
// Get all active threads in the forum.
|
|
@@ -168,6 +171,15 @@ export async function runCronSync(opts) {
|
|
|
168
171
|
try {
|
|
169
172
|
await ensureStatusMessage(client, fullJob.threadId, fullJob.cronId, record, statsStore, { log });
|
|
170
173
|
statusMessagesUpdated++;
|
|
174
|
+
// Update projection hash after successful status message sync.
|
|
175
|
+
const currentHash = computeDefinitionHash(record);
|
|
176
|
+
if (record.projectionHash !== currentHash || record.projectionStatus !== 'synced') {
|
|
177
|
+
await statsStore.upsertRecord(fullJob.cronId, fullJob.threadId, {
|
|
178
|
+
projectionStatus: 'synced',
|
|
179
|
+
projectionSyncedAt: new Date().toISOString(),
|
|
180
|
+
projectionHash: currentHash,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
171
183
|
}
|
|
172
184
|
catch (err) {
|
|
173
185
|
log?.warn({ err, jobId: job.id }, 'cron-sync:phase3 status message failed');
|
|
@@ -231,6 +243,103 @@ export async function runCronSync(opts) {
|
|
|
231
243
|
log?.warn({ threadId: editableThread.id, name: editableThread.name }, 'cron-sync:phase4 orphan thread (no registered job)');
|
|
232
244
|
}
|
|
233
245
|
}
|
|
234
|
-
|
|
235
|
-
|
|
246
|
+
// Phase 5: Projection reconciliation — repair missing/drifted/stale Discord projections
|
|
247
|
+
// from canonical local state. This ensures local records are the source of truth.
|
|
248
|
+
const allRecords = statsStore.getCanonicalDefinitions();
|
|
249
|
+
for (const [cronId, record] of Object.entries(allRecords)) {
|
|
250
|
+
const currentHash = computeDefinitionHash(record);
|
|
251
|
+
const status = record.projectionStatus;
|
|
252
|
+
const needsReconciliation = status === 'missing' ||
|
|
253
|
+
status === 'drifted' ||
|
|
254
|
+
status === 'pending-resync' ||
|
|
255
|
+
(record.projectionHash !== undefined && record.projectionHash !== currentHash);
|
|
256
|
+
if (!needsReconciliation)
|
|
257
|
+
continue;
|
|
258
|
+
if (status === 'missing') {
|
|
259
|
+
// Thread was deleted — recreate from canonical definition.
|
|
260
|
+
if (!record.channel || !record.prompt)
|
|
261
|
+
continue;
|
|
262
|
+
try {
|
|
263
|
+
const baseName = record.prompt.slice(0, 50).replace(/\n/g, ' ').trim() || cronId;
|
|
264
|
+
const cadence = record.cadence ?? null;
|
|
265
|
+
const threadName = buildCronThreadName(baseName, cadence);
|
|
266
|
+
const starterContent = [
|
|
267
|
+
`**Schedule:** \`${record.schedule ?? 'N/A'}\``,
|
|
268
|
+
`**Timezone:** ${record.timezone ?? 'UTC'}`,
|
|
269
|
+
`**Channel:** ${record.channel}`,
|
|
270
|
+
`**Prompt:** ${record.prompt}`,
|
|
271
|
+
`\n[cronId:${cronId}]`,
|
|
272
|
+
].join('\n');
|
|
273
|
+
const newThread = await forum.threads.create({
|
|
274
|
+
name: threadName,
|
|
275
|
+
message: { content: starterContent },
|
|
276
|
+
});
|
|
277
|
+
// Update the record with the new thread ID and mark synced.
|
|
278
|
+
await statsStore.upsertRecord(cronId, newThread.id, {
|
|
279
|
+
projectionStatus: 'synced',
|
|
280
|
+
projectionSyncedAt: new Date().toISOString(),
|
|
281
|
+
projectionHash: currentHash,
|
|
282
|
+
statusMessageId: undefined,
|
|
283
|
+
promptMessageId: undefined,
|
|
284
|
+
});
|
|
285
|
+
// Re-register in scheduler from canonical definition.
|
|
286
|
+
// Clean up any stale scheduler entry first (e.g., from canonical orphan
|
|
287
|
+
// recovery at boot or threadDelete resilience — the cron may still be
|
|
288
|
+
// registered under the old/deleted thread ID).
|
|
289
|
+
const def = {
|
|
290
|
+
triggerType: record.triggerType ?? 'schedule',
|
|
291
|
+
schedule: record.schedule,
|
|
292
|
+
timezone: record.timezone ?? 'UTC',
|
|
293
|
+
channel: record.channel,
|
|
294
|
+
prompt: record.prompt,
|
|
295
|
+
};
|
|
296
|
+
try {
|
|
297
|
+
const staleJob = scheduler.getJobByCronId(cronId);
|
|
298
|
+
if (staleJob && staleJob.id !== newThread.id) {
|
|
299
|
+
scheduler.unregister(staleJob.id);
|
|
300
|
+
}
|
|
301
|
+
scheduler.register(newThread.id, newThread.id, forum.guildId, threadName, def, cronId);
|
|
302
|
+
if (record.disabled)
|
|
303
|
+
scheduler.disable(newThread.id);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// Registration failed — will retry next sync cycle.
|
|
307
|
+
}
|
|
308
|
+
projectionsRepaired++;
|
|
309
|
+
log?.info({ cronId, newThreadId: newThread.id }, 'cron-sync:phase5 recreated missing projection');
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
log?.warn({ err, cronId }, 'cron-sync:phase5 failed to recreate missing projection');
|
|
313
|
+
}
|
|
314
|
+
await sleep(throttleMs);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
// Drifted, pending-resync, or hash mismatch — update status message and mark synced.
|
|
318
|
+
// The status message content was already refreshed in Phase 3 for scheduler-registered
|
|
319
|
+
// jobs; for any remaining records, ensure the projection metadata is up to date.
|
|
320
|
+
try {
|
|
321
|
+
const liveRecord = statsStore.getRecord(cronId);
|
|
322
|
+
if (liveRecord) {
|
|
323
|
+
try {
|
|
324
|
+
await ensureStatusMessage(client, liveRecord.threadId, cronId, liveRecord, statsStore, { log });
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// Thread may not exist; status message update is best-effort.
|
|
328
|
+
}
|
|
329
|
+
await statsStore.upsertRecord(cronId, liveRecord.threadId, {
|
|
330
|
+
projectionStatus: 'synced',
|
|
331
|
+
projectionSyncedAt: new Date().toISOString(),
|
|
332
|
+
projectionHash: currentHash,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
projectionsRepaired++;
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
log?.warn({ err, cronId }, 'cron-sync:phase5 failed to reconcile drifted projection');
|
|
339
|
+
}
|
|
340
|
+
await sleep(throttleMs);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
log?.info({ tagsApplied, namesUpdated, statusMessagesUpdated, promptMessagesCreated, orphansDetected, projectionsRepaired }, 'cron-sync: complete');
|
|
344
|
+
return { tagsApplied, namesUpdated, statusMessagesUpdated, promptMessagesCreated, orphansDetected, projectionsRepaired };
|
|
236
345
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { ChannelType } from 'discord.js';
|
|
3
3
|
import { runCronSync } from './cron-sync.js';
|
|
4
|
+
import { buildCronThreadName } from './discord-sync.js';
|
|
4
5
|
function makeMockRuntime(output) {
|
|
5
6
|
return {
|
|
6
7
|
id: 'other',
|
|
@@ -45,6 +46,15 @@ function makeStatsStore(records) {
|
|
|
45
46
|
recordRun: vi.fn(async () => { }),
|
|
46
47
|
removeRecord: vi.fn(async () => true),
|
|
47
48
|
removeByThreadId: vi.fn(async () => true),
|
|
49
|
+
markProjectionMissing: vi.fn(async () => true),
|
|
50
|
+
markProjectionDrifted: vi.fn(async () => true),
|
|
51
|
+
queueResync: vi.fn(async () => true),
|
|
52
|
+
getCanonicalDefinitions: vi.fn(() => {
|
|
53
|
+
const snapshot = {};
|
|
54
|
+
for (const [id, rec] of Object.entries(store))
|
|
55
|
+
snapshot[id] = { ...rec };
|
|
56
|
+
return snapshot;
|
|
57
|
+
}),
|
|
48
58
|
};
|
|
49
59
|
}
|
|
50
60
|
function makeScheduler(jobs) {
|
|
@@ -56,10 +66,39 @@ function makeScheduler(jobs) {
|
|
|
56
66
|
return undefined;
|
|
57
67
|
return { id: j.id, cronId: j.cronId, threadId: j.threadId, guildId: 'g1', name: j.name, def: { schedule: j.schedule, timezone: 'UTC', channel: 'general', prompt: j.prompt }, cron: null, running: false };
|
|
58
68
|
},
|
|
69
|
+
getJobByCronId: (cronId) => {
|
|
70
|
+
const j = jobs.find((jj) => jj.cronId === cronId);
|
|
71
|
+
if (!j)
|
|
72
|
+
return undefined;
|
|
73
|
+
return { id: j.id, cronId: j.cronId, threadId: j.threadId, guildId: 'g1', name: j.name, def: { schedule: j.schedule, timezone: 'UTC', channel: 'general', prompt: j.prompt }, cron: null, running: false };
|
|
74
|
+
},
|
|
75
|
+
register: vi.fn(),
|
|
76
|
+
unregister: vi.fn(),
|
|
77
|
+
disable: vi.fn(),
|
|
59
78
|
};
|
|
60
79
|
}
|
|
61
80
|
function makeForum(threads) {
|
|
62
|
-
const threadMap = new Map(threads.map((
|
|
81
|
+
const threadMap = new Map(threads.map((fixture) => {
|
|
82
|
+
const thread = {
|
|
83
|
+
id: fixture.id,
|
|
84
|
+
name: fixture.name,
|
|
85
|
+
parentId: fixture.parentId,
|
|
86
|
+
appliedTags: fixture.appliedTags ?? [],
|
|
87
|
+
client: fixture.client ?? { rest: {} },
|
|
88
|
+
};
|
|
89
|
+
thread.edit = fixture.edit ?? vi.fn(async function (payload) {
|
|
90
|
+
if (payload.appliedTags)
|
|
91
|
+
this.appliedTags = payload.appliedTags;
|
|
92
|
+
if (payload.name)
|
|
93
|
+
this.name = payload.name;
|
|
94
|
+
return this;
|
|
95
|
+
});
|
|
96
|
+
thread.setName = fixture.setName ?? vi.fn(async function (name) {
|
|
97
|
+
this.name = name;
|
|
98
|
+
return this;
|
|
99
|
+
});
|
|
100
|
+
return [thread.id, thread];
|
|
101
|
+
}));
|
|
63
102
|
return {
|
|
64
103
|
id: 'forum-1',
|
|
65
104
|
type: ChannelType.GuildForum,
|
|
@@ -177,6 +216,78 @@ describe('runCronSync', () => {
|
|
|
177
216
|
});
|
|
178
217
|
expect(result.namesUpdated).toBe(1);
|
|
179
218
|
});
|
|
219
|
+
it('phase 2: keeps setName bound to the live thread instance', async () => {
|
|
220
|
+
const expectedName = buildCronThreadName('Daily Check', 'daily');
|
|
221
|
+
const edit = vi.fn(async function (payload) {
|
|
222
|
+
void this.client.rest;
|
|
223
|
+
if (payload.name)
|
|
224
|
+
this.name = payload.name;
|
|
225
|
+
return this;
|
|
226
|
+
});
|
|
227
|
+
const setName = async function (name) {
|
|
228
|
+
return this.edit({ name });
|
|
229
|
+
};
|
|
230
|
+
const forum = makeForum([{ id: 'thread-2', name: 'Old Name', parentId: 'forum-1', edit, setName }]);
|
|
231
|
+
const client = makeClient(forum);
|
|
232
|
+
const log = mockLog();
|
|
233
|
+
const record = makeRecord({ cronId: 'cron-2', threadId: 'thread-2', cadence: 'daily', purposeTags: ['monitoring'], model: 'haiku' });
|
|
234
|
+
const scheduler = makeScheduler([{ id: 'thread-2', threadId: 'thread-2', cronId: 'cron-2', name: 'Daily Check', schedule: '0 7 * * *', prompt: 'Check things' }]);
|
|
235
|
+
const result = await runCronSync({
|
|
236
|
+
client: client,
|
|
237
|
+
forumId: 'forum-1',
|
|
238
|
+
scheduler,
|
|
239
|
+
statsStore: makeStatsStore([record]),
|
|
240
|
+
runtime: makeMockRuntime('monitoring'),
|
|
241
|
+
tagMap: { ...defaultTagMap },
|
|
242
|
+
autoTag: false,
|
|
243
|
+
autoTagModel: 'haiku',
|
|
244
|
+
cwd: '/tmp',
|
|
245
|
+
log,
|
|
246
|
+
throttleMs: 0,
|
|
247
|
+
});
|
|
248
|
+
expect(result.namesUpdated).toBe(1);
|
|
249
|
+
const thread = (await forum.threads.fetchActive()).threads.get('thread-2');
|
|
250
|
+
expect(thread.name).toBe(expectedName);
|
|
251
|
+
expect(edit).toHaveBeenCalledWith({ name: expectedName });
|
|
252
|
+
expect(log.warn).not.toHaveBeenCalledWith(expect.objectContaining({ threadId: 'thread-2' }), 'cron-sync:phase2 name update failed');
|
|
253
|
+
});
|
|
254
|
+
it('phase 1: keeps edit bound to the live thread instance', async () => {
|
|
255
|
+
const edit = vi.fn(async function (payload) {
|
|
256
|
+
void this.client.rest;
|
|
257
|
+
if (payload.appliedTags)
|
|
258
|
+
this.appliedTags = payload.appliedTags;
|
|
259
|
+
return this;
|
|
260
|
+
});
|
|
261
|
+
const forum = makeForum([{ id: 'thread-1', name: 'Test Job', parentId: 'forum-1', edit }]);
|
|
262
|
+
const client = makeClient(forum);
|
|
263
|
+
const log = mockLog();
|
|
264
|
+
const record = makeRecord({
|
|
265
|
+
cronId: 'cron-1',
|
|
266
|
+
threadId: 'thread-1',
|
|
267
|
+
cadence: 'daily',
|
|
268
|
+
purposeTags: ['monitoring'],
|
|
269
|
+
model: 'fast',
|
|
270
|
+
});
|
|
271
|
+
const scheduler = makeScheduler([{ id: 'thread-1', threadId: 'thread-1', cronId: 'cron-1', name: 'Test Job', schedule: '0 7 * * *', prompt: 'Monitor health' }]);
|
|
272
|
+
const result = await runCronSync({
|
|
273
|
+
client: client,
|
|
274
|
+
forumId: 'forum-1',
|
|
275
|
+
scheduler,
|
|
276
|
+
statsStore: makeStatsStore([record]),
|
|
277
|
+
runtime: makeMockRuntime('monitoring'),
|
|
278
|
+
tagMap: { ...defaultTagMap },
|
|
279
|
+
autoTag: true,
|
|
280
|
+
autoTagModel: 'haiku',
|
|
281
|
+
cwd: '/tmp',
|
|
282
|
+
log,
|
|
283
|
+
throttleMs: 0,
|
|
284
|
+
});
|
|
285
|
+
expect(result.tagsApplied).toBe(1);
|
|
286
|
+
const thread = (await forum.threads.fetchActive()).threads.get('thread-1');
|
|
287
|
+
expect(thread.appliedTags).toEqual(['tag-1', 'tag-3']);
|
|
288
|
+
expect(edit).toHaveBeenCalledWith({ appliedTags: ['tag-1', 'tag-3'] });
|
|
289
|
+
expect(log.warn).not.toHaveBeenCalledWith(expect.objectContaining({ threadId: 'thread-1' }), 'cron-sync:phase1 tag apply failed');
|
|
290
|
+
});
|
|
180
291
|
it('continues with metadata/status phases when fetchActive fails', async () => {
|
|
181
292
|
const forum = makeForum([]);
|
|
182
293
|
forum.threads.fetchActive.mockRejectedValueOnce(new Error('Discord API failure'));
|
|
@@ -12,7 +12,7 @@ function mockLog() {
|
|
|
12
12
|
}
|
|
13
13
|
function makeCoordinator() {
|
|
14
14
|
return {
|
|
15
|
-
sync: vi.fn(async () => ({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 })),
|
|
15
|
+
sync: vi.fn(async () => ({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0, projectionsRepaired: 0 })),
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
describe('startCronTagMapWatcher', () => {
|
package/dist/cron/executor.js
CHANGED
|
@@ -9,6 +9,7 @@ import { ensureStatusMessage } from './discord-sync.js';
|
|
|
9
9
|
import { globalMetrics } from '../observability/metrics.js';
|
|
10
10
|
import { mapRuntimeErrorToUserMessage } from '../discord/user-errors.js';
|
|
11
11
|
import { resolveModel } from '../runtime/model-tiers.js';
|
|
12
|
+
import { resolveGroundedToolCapabilities } from '../runtime/tool-capabilities.js';
|
|
12
13
|
import { buildCronPromptBody } from './cron-prompt.js';
|
|
13
14
|
import { buildTieredDiscordActionsPromptSection } from '../discord/actions.js';
|
|
14
15
|
import { handleJsonRouteOutput } from './json-router.js';
|
|
@@ -192,7 +193,12 @@ export async function executeCronJob(job, ctx) {
|
|
|
192
193
|
const cronActionFlags = actionRequester.trusted
|
|
193
194
|
? ctx.actionFlags
|
|
194
195
|
: withoutRequesterGatedActionFlags(ctx.actionFlags);
|
|
195
|
-
let prompt = buildPromptPreamble(inlinedContext
|
|
196
|
+
let prompt = buildPromptPreamble(inlinedContext, {
|
|
197
|
+
runtimeId: ctx.runtime.id,
|
|
198
|
+
runtimeCapabilities: ctx.runtime.capabilities,
|
|
199
|
+
runtimeTools: ctx.tools,
|
|
200
|
+
enableHybridPipeline: ctx.enableHybridPipeline,
|
|
201
|
+
}) +
|
|
196
202
|
'\n\n' +
|
|
197
203
|
buildCronPromptBody({
|
|
198
204
|
jobName: job.name,
|
|
@@ -213,7 +219,7 @@ export async function executeCronJob(job, ctx) {
|
|
|
213
219
|
const tools = await resolveEffectiveTools({
|
|
214
220
|
workspaceCwd: ctx.cwd,
|
|
215
221
|
runtimeTools: ctx.tools,
|
|
216
|
-
runtimeCapabilities: ctx.runtime
|
|
222
|
+
runtimeCapabilities: resolveGroundedToolCapabilities(ctx.runtime),
|
|
217
223
|
runtimeId: ctx.runtime.id,
|
|
218
224
|
log: ctx.log,
|
|
219
225
|
});
|