clementine-agent 1.0.28 → 1.0.30
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/dist/agent/assistant.d.ts +13 -3
- package/dist/agent/assistant.js +29 -10
- package/dist/channels/slack.js +25 -11
- package/dist/gateway/cron-scheduler.d.ts +15 -0
- package/dist/gateway/cron-scheduler.js +99 -3
- package/dist/gateway/router.d.ts +7 -0
- package/dist/gateway/router.js +17 -0
- package/package.json +1 -1
|
@@ -12,9 +12,19 @@
|
|
|
12
12
|
import type { AgentProfile, OnTextCallback, OnToolActivityCallback, VerboseLevel } from '../types.js';
|
|
13
13
|
import { AgentManager } from './agent-manager.js';
|
|
14
14
|
/**
|
|
15
|
-
* Estimate token count
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* Estimate token count for Claude.
|
|
16
|
+
*
|
|
17
|
+
* Anthropic's published rule of thumb is ~3.5 chars/token for English prose.
|
|
18
|
+
* Clementine's prompts blend English guidance with code, JSON, YAML, and
|
|
19
|
+
* structured memory — so we use 3.3 chars/token, slightly denser than pure
|
|
20
|
+
* English, which tracks within ~10% of the SDK's reported input_tokens in
|
|
21
|
+
* practice (see audit.jsonl tokens_in for live calibration).
|
|
22
|
+
*
|
|
23
|
+
* The previous weighted-regex heuristic (words×1.3 + punct×0.8 + lines×0.5)
|
|
24
|
+
* systematically undercounted code and JSON, triggering spurious compactions.
|
|
25
|
+
*
|
|
26
|
+
* Callers that need exact counts should read `usage.input_tokens` from the
|
|
27
|
+
* SDK result; this function is for pre-flight planning only.
|
|
18
28
|
*/
|
|
19
29
|
export declare function estimateTokens(text: string): number;
|
|
20
30
|
export interface ProjectMeta {
|
package/dist/agent/assistant.js
CHANGED
|
@@ -144,20 +144,24 @@ function getChannelToolDenyList(channel) {
|
|
|
144
144
|
}
|
|
145
145
|
// ── Token estimation & context window guard ─────────────────────────
|
|
146
146
|
/**
|
|
147
|
-
* Estimate token count
|
|
148
|
-
*
|
|
149
|
-
*
|
|
147
|
+
* Estimate token count for Claude.
|
|
148
|
+
*
|
|
149
|
+
* Anthropic's published rule of thumb is ~3.5 chars/token for English prose.
|
|
150
|
+
* Clementine's prompts blend English guidance with code, JSON, YAML, and
|
|
151
|
+
* structured memory — so we use 3.3 chars/token, slightly denser than pure
|
|
152
|
+
* English, which tracks within ~10% of the SDK's reported input_tokens in
|
|
153
|
+
* practice (see audit.jsonl tokens_in for live calibration).
|
|
154
|
+
*
|
|
155
|
+
* The previous weighted-regex heuristic (words×1.3 + punct×0.8 + lines×0.5)
|
|
156
|
+
* systematically undercounted code and JSON, triggering spurious compactions.
|
|
157
|
+
*
|
|
158
|
+
* Callers that need exact counts should read `usage.input_tokens` from the
|
|
159
|
+
* SDK result; this function is for pre-flight planning only.
|
|
150
160
|
*/
|
|
151
161
|
export function estimateTokens(text) {
|
|
152
162
|
if (!text)
|
|
153
163
|
return 0;
|
|
154
|
-
|
|
155
|
-
const words = text.match(/\b\w+\b/g)?.length ?? 0;
|
|
156
|
-
// Count non-word tokens: punctuation, brackets, operators (each is ~1 token)
|
|
157
|
-
const punctuation = text.match(/[^\w\s]/g)?.length ?? 0;
|
|
158
|
-
// Newlines and indentation: roughly 1 token per line
|
|
159
|
-
const lines = text.split('\n').length;
|
|
160
|
-
return Math.ceil(words * 1.3 + punctuation * 0.8 + lines * 0.5);
|
|
164
|
+
return Math.ceil(text.length / 3.3);
|
|
161
165
|
}
|
|
162
166
|
/**
|
|
163
167
|
* Strip lone Unicode surrogates (U+D800–U+DFFF) from a string so it can be
|
|
@@ -765,6 +769,21 @@ export class PersonalAssistant {
|
|
|
765
769
|
try {
|
|
766
770
|
const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf-8'));
|
|
767
771
|
const now = Date.now();
|
|
772
|
+
// Drop old-format Slack session keys that pre-date workspace namespacing
|
|
773
|
+
// (`slack:user:*`, `slack:dm:*`). The new format is
|
|
774
|
+
// `slack:team:{teamId}:user:{userId}`; old keys can't be safely remapped
|
|
775
|
+
// because the originating workspace isn't known, so they're dropped and
|
|
776
|
+
// users rotate into a fresh session on their next message.
|
|
777
|
+
let droppedLegacy = 0;
|
|
778
|
+
for (const key of Object.keys(data)) {
|
|
779
|
+
if (/^slack:(user|dm):/.test(key)) {
|
|
780
|
+
delete data[key];
|
|
781
|
+
droppedLegacy++;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (droppedLegacy > 0) {
|
|
785
|
+
logger.info({ dropped: droppedLegacy }, 'Migrated sessions: dropped pre-workspace-namespacing Slack keys');
|
|
786
|
+
}
|
|
768
787
|
for (const [key, entry] of Object.entries(data)) {
|
|
769
788
|
const ts = new Date(entry.timestamp);
|
|
770
789
|
if (now - ts.getTime() > SESSION_EXPIRY_MS)
|
package/dist/channels/slack.js
CHANGED
|
@@ -59,7 +59,7 @@ export async function startSlack(gateway, dispatcher, slackBotManager) {
|
|
|
59
59
|
app.error(async (error) => {
|
|
60
60
|
logger.error({ err: error }, 'Slack app error — continuing');
|
|
61
61
|
});
|
|
62
|
-
app.message(async ({ message, client }) => {
|
|
62
|
+
app.message(async ({ message, client, context }) => {
|
|
63
63
|
try {
|
|
64
64
|
// Type guard: only handle regular user messages
|
|
65
65
|
if (!('user' in message) || !('text' in message))
|
|
@@ -72,6 +72,10 @@ export async function startSlack(gateway, dispatcher, slackBotManager) {
|
|
|
72
72
|
if (slackBotManager?.getOwnedChannelIds().includes(message.channel))
|
|
73
73
|
return;
|
|
74
74
|
const userId = message.user;
|
|
75
|
+
// Slack user IDs are scoped per-workspace, so a bare `slack:user:{uid}`
|
|
76
|
+
// collides across workspaces. Namespace by team/workspace ID so sessions
|
|
77
|
+
// stay isolated even when the same bot is installed in multiple workspaces.
|
|
78
|
+
const teamId = context.teamId ?? (await client.auth.test().then(r => r.team_id).catch(() => 'unknown'));
|
|
75
79
|
// Owner-only check
|
|
76
80
|
if (SLACK_OWNER_USER_ID && userId !== SLACK_OWNER_USER_ID) {
|
|
77
81
|
logger.warn(`Ignored Slack message from non-owner: ${userId}`);
|
|
@@ -93,7 +97,7 @@ export async function startSlack(gateway, dispatcher, slackBotManager) {
|
|
|
93
97
|
return;
|
|
94
98
|
const channel = message.channel;
|
|
95
99
|
const threadTs = ('thread_ts' in message ? message.thread_ts : undefined) ?? message.ts;
|
|
96
|
-
const sessionKey = `slack:user:${userId}`;
|
|
100
|
+
const sessionKey = `slack:team:${teamId}:user:${userId}`;
|
|
97
101
|
// ── !stop — abort active query (bypasses session lock) ────────────
|
|
98
102
|
if (text === '!stop' || text === '/stop') {
|
|
99
103
|
const stopped = gateway.stopSession(sessionKey);
|
|
@@ -201,32 +205,42 @@ export async function startSlack(gateway, dispatcher, slackBotManager) {
|
|
|
201
205
|
* Returns true on success.
|
|
202
206
|
*
|
|
203
207
|
* Session key formats:
|
|
204
|
-
* slack:user:{userId}
|
|
208
|
+
* slack:team:{teamId}:user:{userId} → DM to user (workspace-namespaced, current format)
|
|
209
|
+
* slack:team:{teamId}:dm:{userId} → DM to user (workspace-namespaced)
|
|
210
|
+
* slack:user:{userId} → DM to user (legacy, pre-namespacing)
|
|
211
|
+
* slack:dm:{userId} → DM to user (legacy)
|
|
205
212
|
* slack:channel:{channelId}:{userId} → post in channel
|
|
206
213
|
* slack:channel:{channelId}:{slug}:{userId} → post in channel (agent-scoped chat)
|
|
207
|
-
* slack:dm:{userId} → DM to user
|
|
208
214
|
* slack:agent:{slug}:{userId} → DM to user (agent-scoped)
|
|
209
215
|
*/
|
|
210
216
|
async function trySlackSessionRouting(sessionKey, text) {
|
|
211
217
|
const parts = sessionKey.split(':');
|
|
212
218
|
if (parts[0] !== 'slack' || parts.length < 3)
|
|
213
219
|
return false;
|
|
214
|
-
|
|
220
|
+
// Strip the `team:{teamId}:` workspace prefix if present so downstream
|
|
221
|
+
// routing logic stays format-agnostic. The current bolt app is connected
|
|
222
|
+
// to a single workspace, so we use the existing client regardless of which
|
|
223
|
+
// teamId the session names.
|
|
224
|
+
let effectiveParts = parts;
|
|
225
|
+
if (parts[1] === 'team' && parts.length >= 4) {
|
|
226
|
+
effectiveParts = ['slack', ...parts.slice(3)];
|
|
227
|
+
}
|
|
228
|
+
const kind = effectiveParts[1];
|
|
215
229
|
try {
|
|
216
|
-
if ((kind === 'user' || kind === 'dm') &&
|
|
217
|
-
const dm = await app.client.conversations.open({ users:
|
|
230
|
+
if ((kind === 'user' || kind === 'dm') && effectiveParts[2]) {
|
|
231
|
+
const dm = await app.client.conversations.open({ users: effectiveParts[2] });
|
|
218
232
|
const channelId = dm.channel?.id;
|
|
219
233
|
if (!channelId)
|
|
220
234
|
return false;
|
|
221
235
|
await sendChunkedSlack(app.client, channelId, mdToSlack(text));
|
|
222
236
|
return true;
|
|
223
237
|
}
|
|
224
|
-
if (kind === 'channel' &&
|
|
225
|
-
await sendChunkedSlack(app.client,
|
|
238
|
+
if (kind === 'channel' && effectiveParts[2]) {
|
|
239
|
+
await sendChunkedSlack(app.client, effectiveParts[2], mdToSlack(text));
|
|
226
240
|
return true;
|
|
227
241
|
}
|
|
228
|
-
if (kind === 'agent' &&
|
|
229
|
-
const dm = await app.client.conversations.open({ users:
|
|
242
|
+
if (kind === 'agent' && effectiveParts[3]) {
|
|
243
|
+
const dm = await app.client.conversations.open({ users: effectiveParts[3] });
|
|
230
244
|
const channelId = dm.channel?.id;
|
|
231
245
|
if (!channelId)
|
|
232
246
|
return false;
|
|
@@ -60,6 +60,7 @@ export declare class CronScheduler {
|
|
|
60
60
|
private disabledJobs;
|
|
61
61
|
private scheduledTasks;
|
|
62
62
|
private runningJobs;
|
|
63
|
+
private runMetadata;
|
|
63
64
|
private completedJobs;
|
|
64
65
|
private watching;
|
|
65
66
|
readonly runLog: CronRunLog;
|
|
@@ -71,7 +72,21 @@ export declare class CronScheduler {
|
|
|
71
72
|
private goalTriggerDir;
|
|
72
73
|
private triggerTimer;
|
|
73
74
|
private statusChangeListeners;
|
|
75
|
+
private static readonly RUNNING_JOBS_FILE;
|
|
74
76
|
constructor(gateway: Gateway, dispatcher: NotificationDispatcher);
|
|
77
|
+
/**
|
|
78
|
+
* Atomically persist the current runningJobs set to disk. Uses write-then-
|
|
79
|
+
* rename so a crash mid-write cannot corrupt the file.
|
|
80
|
+
*/
|
|
81
|
+
private persistRunningJobs;
|
|
82
|
+
/**
|
|
83
|
+
* On startup, read the persisted running-jobs file. Any entries present
|
|
84
|
+
* represent jobs interrupted by a previous crash. Surface each to audit.jsonl
|
|
85
|
+
* and clear the file. Deliberately do NOT auto-restart — the next scheduled
|
|
86
|
+
* tick handles it, avoiding duplicate external side effects (emails sent,
|
|
87
|
+
* commits pushed, etc.) from a partial prior run.
|
|
88
|
+
*/
|
|
89
|
+
private reconcileInterruptedJobs;
|
|
75
90
|
/** Load job definitions from CRON.md and agent dirs without scheduling tasks. */
|
|
76
91
|
private loadJobDefinitions;
|
|
77
92
|
/** Register a listener that fires when system state changes (job start/finish, self-improve, etc). */
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* retry helpers, CronRunLog, and daily-note logging utilities used by both schedulers.
|
|
8
8
|
*/
|
|
9
9
|
import { execSync } from 'node:child_process';
|
|
10
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, watchFile, unwatchFile, writeFileSync, } from 'node:fs';
|
|
10
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, watchFile, unwatchFile, writeFileSync, } from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import cron from 'node-cron';
|
|
13
13
|
import matter from 'gray-matter';
|
|
@@ -17,6 +17,7 @@ import { listAllGoals, findGoalPath, readGoalById } from '../tools/shared.js';
|
|
|
17
17
|
import { scanner } from '../security/scanner.js';
|
|
18
18
|
import { parseAllWorkflows as parseAllWorkflowsSync } from '../agent/workflow-runner.js';
|
|
19
19
|
import { SelfImproveLoop } from '../agent/self-improve.js';
|
|
20
|
+
import { logAuditJsonl } from '../agent/hooks.js';
|
|
20
21
|
const logger = pino({ name: 'clementine.cron' });
|
|
21
22
|
/** Default timeout for standard cron jobs (10 minutes). */
|
|
22
23
|
const CRON_STANDARD_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -332,6 +333,7 @@ export class CronScheduler {
|
|
|
332
333
|
disabledJobs = new Set();
|
|
333
334
|
scheduledTasks = new Map();
|
|
334
335
|
runningJobs = new Set();
|
|
336
|
+
runMetadata = new Map();
|
|
335
337
|
completedJobs = new Map(); // jobName → completion timestamp
|
|
336
338
|
watching = false;
|
|
337
339
|
runLog;
|
|
@@ -346,6 +348,10 @@ export class CronScheduler {
|
|
|
346
348
|
triggerTimer = null;
|
|
347
349
|
// Event-driven status change listeners (used by Discord status embed)
|
|
348
350
|
statusChangeListeners = [];
|
|
351
|
+
// Disk-backed mirror of runningJobs for crash-safe idempotency. If the
|
|
352
|
+
// daemon dies mid-run, startup reconciliation surfaces the interrupted job
|
|
353
|
+
// to audit.jsonl and clears the file so the next scheduled tick proceeds.
|
|
354
|
+
static RUNNING_JOBS_FILE = path.join(BASE_DIR, 'cron-running.json');
|
|
349
355
|
constructor(gateway, dispatcher) {
|
|
350
356
|
this.gateway = gateway;
|
|
351
357
|
this.dispatcher = dispatcher;
|
|
@@ -355,6 +361,65 @@ export class CronScheduler {
|
|
|
355
361
|
// query jobs on connect which happens before start().
|
|
356
362
|
this.loadJobDefinitions();
|
|
357
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Atomically persist the current runningJobs set to disk. Uses write-then-
|
|
366
|
+
* rename so a crash mid-write cannot corrupt the file.
|
|
367
|
+
*/
|
|
368
|
+
persistRunningJobs(metaByName) {
|
|
369
|
+
try {
|
|
370
|
+
const entries = [...this.runningJobs].map(name => ({
|
|
371
|
+
jobName: name,
|
|
372
|
+
startedAt: metaByName?.get(name)?.startedAt ?? new Date().toISOString(),
|
|
373
|
+
runId: metaByName?.get(name)?.runId ?? '',
|
|
374
|
+
pid: process.pid,
|
|
375
|
+
}));
|
|
376
|
+
const tmp = CronScheduler.RUNNING_JOBS_FILE + '.tmp';
|
|
377
|
+
writeFileSync(tmp, JSON.stringify(entries, null, 2));
|
|
378
|
+
renameSync(tmp, CronScheduler.RUNNING_JOBS_FILE);
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
logger.debug({ err }, 'Failed to persist running-jobs file');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* On startup, read the persisted running-jobs file. Any entries present
|
|
386
|
+
* represent jobs interrupted by a previous crash. Surface each to audit.jsonl
|
|
387
|
+
* and clear the file. Deliberately do NOT auto-restart — the next scheduled
|
|
388
|
+
* tick handles it, avoiding duplicate external side effects (emails sent,
|
|
389
|
+
* commits pushed, etc.) from a partial prior run.
|
|
390
|
+
*/
|
|
391
|
+
reconcileInterruptedJobs() {
|
|
392
|
+
try {
|
|
393
|
+
if (!existsSync(CronScheduler.RUNNING_JOBS_FILE))
|
|
394
|
+
return;
|
|
395
|
+
const raw = readFileSync(CronScheduler.RUNNING_JOBS_FILE, 'utf-8');
|
|
396
|
+
const entries = JSON.parse(raw);
|
|
397
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
398
|
+
unlinkSync(CronScheduler.RUNNING_JOBS_FILE);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const detectedAt = new Date().toISOString();
|
|
402
|
+
for (const entry of entries) {
|
|
403
|
+
logger.warn({ ...entry, detectedAt }, 'Interrupted cron job detected on startup');
|
|
404
|
+
logAuditJsonl({
|
|
405
|
+
event_type: 'cron_interrupted',
|
|
406
|
+
jobName: entry.jobName,
|
|
407
|
+
runId: entry.runId,
|
|
408
|
+
startedAt: entry.startedAt,
|
|
409
|
+
detectedAt,
|
|
410
|
+
previousPid: entry.pid,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
unlinkSync(CronScheduler.RUNNING_JOBS_FILE);
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
logger.warn({ err }, 'Failed to reconcile running-jobs file — starting fresh');
|
|
417
|
+
try {
|
|
418
|
+
unlinkSync(CronScheduler.RUNNING_JOBS_FILE);
|
|
419
|
+
}
|
|
420
|
+
catch { /* ignore */ }
|
|
421
|
+
}
|
|
422
|
+
}
|
|
358
423
|
/** Load job definitions from CRON.md and agent dirs without scheduling tasks. */
|
|
359
424
|
loadJobDefinitions() {
|
|
360
425
|
this.jobs = parseCronJobs();
|
|
@@ -376,15 +441,25 @@ export class CronScheduler {
|
|
|
376
441
|
}
|
|
377
442
|
}
|
|
378
443
|
start() {
|
|
444
|
+
// Surface any jobs that were mid-run when the daemon last died and clear
|
|
445
|
+
// the crash-consistency file before scheduling new ticks.
|
|
446
|
+
this.reconcileInterruptedJobs();
|
|
379
447
|
this.reloadJobs();
|
|
380
448
|
this.reloadWorkflows();
|
|
381
449
|
this.watchCronFile();
|
|
382
450
|
this.watchAgentsDir();
|
|
383
451
|
this.watchWorkflowDir();
|
|
384
452
|
this.watchTriggers();
|
|
453
|
+
// Deep-mode jobs are owned by the router (_deliverDeepResult). The
|
|
454
|
+
// cron-scheduler callbacks below only dispatch for cron-originated runs;
|
|
455
|
+
// phase updates for deep-mode runs get routed back to the originating
|
|
456
|
+
// session instead of fanning out to every registered channel.
|
|
457
|
+
const isDeepMode = (jobName) => jobName.startsWith('deep-');
|
|
385
458
|
// Wire up push notifications for unleashed task completions
|
|
386
459
|
this.gateway.setUnleashedCompleteCallback((jobName, result) => {
|
|
387
460
|
this.completedJobs.set(jobName, Date.now());
|
|
461
|
+
if (isDeepMode(jobName))
|
|
462
|
+
return; // router handles delivery via _deliverDeepResult
|
|
388
463
|
if (result && result !== '__NOTHING__') {
|
|
389
464
|
const slug = jobName.includes(':') ? jobName.split(':')[0] : undefined;
|
|
390
465
|
// Strip system metadata for clean conversational delivery
|
|
@@ -405,7 +480,15 @@ export class CronScheduler {
|
|
|
405
480
|
const cleanOutput = output
|
|
406
481
|
.replace(/^STATUS SUMMARY:?\s*/im, '')
|
|
407
482
|
.slice(0, 500);
|
|
408
|
-
|
|
483
|
+
// For deep-mode runs, target the originating session so the progress
|
|
484
|
+
// update lands in the same Discord DM / Slack thread / dashboard window.
|
|
485
|
+
const deepSessionKey = isDeepMode(jobName) ? this.gateway.findDeepTaskSessionKey(jobName) : null;
|
|
486
|
+
const ctx = {};
|
|
487
|
+
if (slug)
|
|
488
|
+
ctx.agentSlug = slug;
|
|
489
|
+
if (deepSessionKey)
|
|
490
|
+
ctx.sessionKey = deepSessionKey;
|
|
491
|
+
this.dispatcher.send(`Still working on it — ${cleanOutput}`, ctx).catch(err => logger.debug({ err }, 'Failed to send phase progress notification'));
|
|
409
492
|
});
|
|
410
493
|
// Wire up real-time progress summaries (throttled to max 1 per 5 minutes)
|
|
411
494
|
const lastProgressSent = new Map();
|
|
@@ -416,7 +499,13 @@ export class CronScheduler {
|
|
|
416
499
|
return; // throttle: 1 per 5 minutes
|
|
417
500
|
lastProgressSent.set(jobName, now);
|
|
418
501
|
const slug = jobName.includes(':') ? jobName.split(':')[0] : undefined;
|
|
419
|
-
|
|
502
|
+
const deepSessionKey = isDeepMode(jobName) ? this.gateway.findDeepTaskSessionKey(jobName) : null;
|
|
503
|
+
const ctx = {};
|
|
504
|
+
if (slug)
|
|
505
|
+
ctx.agentSlug = slug;
|
|
506
|
+
if (deepSessionKey)
|
|
507
|
+
ctx.sessionKey = deepSessionKey;
|
|
508
|
+
this.dispatcher.send(summary.slice(0, 300), ctx).catch(err => logger.debug({ err }, 'Failed to send phase progress summary'));
|
|
420
509
|
});
|
|
421
510
|
logger.info(`Cron scheduler started with ${this.jobs.length} jobs`);
|
|
422
511
|
}
|
|
@@ -800,6 +889,11 @@ export class CronScheduler {
|
|
|
800
889
|
catch { /* non-fatal */ }
|
|
801
890
|
}
|
|
802
891
|
this.runningJobs.add(job.name);
|
|
892
|
+
this.runMetadata.set(job.name, {
|
|
893
|
+
startedAt: new Date().toISOString(),
|
|
894
|
+
runId: Math.random().toString(36).slice(2, 10),
|
|
895
|
+
});
|
|
896
|
+
this.persistRunningJobs(this.runMetadata);
|
|
803
897
|
this.emitStatusChange();
|
|
804
898
|
try {
|
|
805
899
|
logger.info(`Running cron job: ${job.name}${job.agentSlug ? ` (agent: ${job.agentSlug})` : ''}`);
|
|
@@ -969,6 +1063,8 @@ export class CronScheduler {
|
|
|
969
1063
|
}
|
|
970
1064
|
finally {
|
|
971
1065
|
this.runningJobs.delete(job.name);
|
|
1066
|
+
this.runMetadata.delete(job.name);
|
|
1067
|
+
this.persistRunningJobs(this.runMetadata);
|
|
972
1068
|
this.emitStatusChange();
|
|
973
1069
|
// Fire-and-forget: check if this agent's profile needs self-learning update
|
|
974
1070
|
if (job.agentSlug) {
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -75,6 +75,13 @@ export declare class Gateway {
|
|
|
75
75
|
constructor(assistant: PersonalAssistant);
|
|
76
76
|
/** Get or create a session state entry. */
|
|
77
77
|
private getSession;
|
|
78
|
+
/**
|
|
79
|
+
* Reverse-lookup the session key that owns a given deep-mode jobName.
|
|
80
|
+
* Used by the cron-scheduler callbacks so phase-progress and completion
|
|
81
|
+
* messages can be routed back to the originating channel instead of
|
|
82
|
+
* fanning out to every registered sender.
|
|
83
|
+
*/
|
|
84
|
+
findDeepTaskSessionKey(jobName: string): string | null;
|
|
78
85
|
getAgentManager(): AgentManager;
|
|
79
86
|
getTeamRouter(): TeamRouter;
|
|
80
87
|
getTeamBus(): TeamBus;
|
package/dist/gateway/router.js
CHANGED
|
@@ -322,6 +322,19 @@ export class Gateway {
|
|
|
322
322
|
}
|
|
323
323
|
return s;
|
|
324
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Reverse-lookup the session key that owns a given deep-mode jobName.
|
|
327
|
+
* Used by the cron-scheduler callbacks so phase-progress and completion
|
|
328
|
+
* messages can be routed back to the originating channel instead of
|
|
329
|
+
* fanning out to every registered sender.
|
|
330
|
+
*/
|
|
331
|
+
findDeepTaskSessionKey(jobName) {
|
|
332
|
+
for (const [key, sess] of this.sessions) {
|
|
333
|
+
if (sess.deepTask?.jobName === jobName)
|
|
334
|
+
return key;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
325
338
|
// ── Team system accessors ──────────────────────────────────────────
|
|
326
339
|
getAgentManager() {
|
|
327
340
|
if (!this._agentManager) {
|
|
@@ -748,6 +761,8 @@ export class Gateway {
|
|
|
748
761
|
const isOwnerDm = sessionKey.startsWith('discord:user:') ||
|
|
749
762
|
sessionKey.startsWith('discord:agent:') ||
|
|
750
763
|
sessionKey.startsWith('slack:dm:') ||
|
|
764
|
+
// New workspace-namespaced Slack DMs: slack:team:{teamId}:user:{userId}
|
|
765
|
+
/^slack:team:[^:]+:(user|dm):/.test(sessionKey) ||
|
|
751
766
|
sessionKey.startsWith('telegram:');
|
|
752
767
|
const shouldBlock = scan.verdict === 'block' && !isOwnerDm;
|
|
753
768
|
if (shouldBlock) {
|
|
@@ -1308,6 +1323,8 @@ export class Gateway {
|
|
|
1308
1323
|
const isOwnerDm = sessionKey.startsWith('discord:user:') ||
|
|
1309
1324
|
sessionKey.startsWith('discord:agent:') ||
|
|
1310
1325
|
sessionKey.startsWith('slack:dm:') ||
|
|
1326
|
+
// New workspace-namespaced Slack DMs: slack:team:{teamId}:user:{userId}
|
|
1327
|
+
/^slack:team:[^:]+:(user|dm):/.test(sessionKey) ||
|
|
1311
1328
|
sessionKey.startsWith('telegram:');
|
|
1312
1329
|
const shouldBlock = scan.verdict === 'block' && !isOwnerDm;
|
|
1313
1330
|
if (shouldBlock) {
|