@yemi33/minions 0.1.2424 → 0.1.2425
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/bin/install-internal-minions.js +1209 -0
- package/bin/minions.js +75 -9
- package/dashboard/docs/typography.md +27 -12
- package/dashboard/js/command-center.js +13 -11
- package/dashboard/js/live-stream.js +1 -1
- package/dashboard/js/memory-search.js +388 -43
- package/dashboard/js/modal-qa.js +5 -5
- package/dashboard/js/qa.js +39 -26
- package/dashboard/js/refresh.js +37 -18
- package/dashboard/js/render-agents.js +14 -18
- package/dashboard/js/render-dispatch.js +14 -74
- package/dashboard/js/render-inbox.js +3 -3
- package/dashboard/js/render-meetings.js +8 -8
- package/dashboard/js/render-other.js +45 -20
- package/dashboard/js/render-pipelines.js +6 -6
- package/dashboard/js/render-plans.js +14 -14
- package/dashboard/js/render-prd.js +46 -46
- package/dashboard/js/render-prs.js +9 -65
- package/dashboard/js/render-schedules.js +5 -5
- package/dashboard/js/render-skills.js +6 -6
- package/dashboard/js/render-utils.js +4 -4
- package/dashboard/js/render-watches.js +4 -16
- package/dashboard/js/render-work-items.js +95 -107
- package/dashboard/js/settings.js +175 -74
- package/dashboard/layout.html +3 -2
- package/dashboard/pages/inbox.html +1 -1
- package/dashboard/pages/work.html +1 -1
- package/dashboard/shared/model-display.js +9 -0
- package/dashboard/shared/watches-source.js +43 -0
- package/dashboard/shared/welcome-popup.js +259 -0
- package/dashboard/slim/body.html +6 -6
- package/dashboard/slim/js/chat.js +4 -2
- package/dashboard/slim/js/history.js +10 -3
- package/dashboard/slim/js/modals-tiles.js +85 -16
- package/dashboard/slim/js/status.js +12 -16
- package/dashboard/slim/styles.css +24 -4
- package/dashboard/styles.css +62 -27
- package/dashboard-build.js +8 -2
- package/dashboard.js +3854 -2514
- package/docs/README.md +6 -4
- package/docs/api-errors.md +144 -0
- package/docs/auto-discovery.md +83 -61
- package/docs/capture-demos.js +173 -30
- package/docs/command-center.md +5 -1
- package/docs/completion-reports.md +127 -11
- package/docs/constants.md +15 -3
- package/docs/constellation-style-telemetry.md +2 -2
- package/docs/copilot-cli-schema.md +32 -22
- package/docs/cross-repo-plans.md +30 -30
- package/docs/demo/memory-system.html +1 -1
- package/docs/deprecated.json +132 -18
- package/docs/diagnostics-memory.md +8 -4
- package/docs/human-vs-automated.md +2 -2
- package/docs/internal-install.md +190 -0
- package/docs/kb-pr3223-cascade-archiving.md +16 -0
- package/docs/kb-pr696-merge-conflict-docs.md +23 -0
- package/docs/kb-sweep.md +2 -2
- package/docs/keep-processes.md +8 -1
- package/docs/live-checkout-mode.md +1 -1
- package/docs/managed-spawn.md +21 -3
- package/docs/named-agents.md +3 -2
- package/docs/plan-lifecycle.md +32 -32
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/qa-runbook-lifecycle.md +67 -51
- package/docs/qa-runbooks.md +2 -2
- package/docs/runtime-adapters.md +101 -50
- package/docs/security.md +28 -0
- package/docs/self-improvement.md +47 -13
- package/docs/skills.md +29 -0
- package/docs/slim-ux/concepts.md +2 -2
- package/docs/specs/agent-configurability.md +43 -41
- package/docs/specs/agent-rename.md +13 -13
- package/docs/team-memory.md +283 -14
- package/docs/tutorials/03-plan-driven-feature.md +3 -3
- package/docs/tutorials/04-runtimes-and-harness.md +18 -21
- package/docs/tutorials/08-operations-and-recovery.md +3 -2
- package/docs/tutorials/README.md +4 -3
- package/docs/visual-evidence-ci.md +103 -0
- package/docs/watches.md +35 -30
- package/docs/worktree-lifecycle.md +151 -13
- package/engine/acp-transport.js +1 -0
- package/engine/ado.js +80 -33
- package/engine/agent-api-validation.js +571 -0
- package/engine/agent-worker-pool.js +7 -6
- package/engine/api-contracts/agent-content.js +761 -0
- package/engine/api-contracts/cc-ops.js +219 -0
- package/engine/api-contracts/config-runtime.js +419 -0
- package/engine/api-contracts/core.js +138 -0
- package/engine/api-contracts/index.js +506 -0
- package/engine/api-contracts/orchestration.js +602 -0
- package/engine/api-contracts/pull-requests.js +323 -0
- package/engine/api-contracts/qa-process.js +764 -0
- package/engine/api-contracts/work-plan-prd.js +494 -0
- package/engine/api-validation.js +543 -0
- package/engine/cc-api-validation.js +586 -0
- package/engine/cc-worker-pool.js +9 -1
- package/engine/cleanup.js +393 -271
- package/engine/cli.js +65 -7
- package/engine/comment-classifier.js +70 -5
- package/engine/consolidation.js +123 -5
- package/engine/content-api-validation.js +606 -0
- package/engine/create-pr-worktree.js +22 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
- package/engine/dispatch.js +33 -0
- package/engine/distribution.js +189 -0
- package/engine/execution-model.js +59 -0
- package/engine/features.js +12 -0
- package/engine/github.js +67 -25
- package/engine/harness-context.js +313 -0
- package/engine/inbox-store.js +1 -1
- package/engine/kb-sweep.js +161 -38
- package/engine/keep-process-sweep.js +43 -3
- package/engine/lifecycle.js +1066 -165
- package/engine/llm.js +4 -0
- package/engine/managed-spawn.js +463 -48
- package/engine/meeting.js +197 -34
- package/engine/memory-retrieval.js +280 -0
- package/engine/memory-store.js +821 -38
- package/engine/model-discovery.js +17 -3
- package/engine/pipeline.js +802 -14
- package/engine/plan-prd-validation.js +745 -0
- package/engine/playbook.js +206 -17
- package/engine/pooled-agent-process.js +6 -1
- package/engine/pr-action.js +1 -1
- package/engine/pr-issue-validation.js +653 -0
- package/engine/pr-resolve.js +75 -13
- package/engine/prd-store.js +39 -6
- package/engine/preflight.js +7 -2
- package/engine/process-utils.js +79 -31
- package/engine/projects.js +198 -12
- package/engine/promotion.js +371 -0
- package/engine/qa-from-prd.js +3 -0
- package/engine/qa-process-validation.js +572 -0
- package/engine/qa-runbooks.js +41 -37
- package/engine/qa-runners/maestro.js +11 -7
- package/engine/qa-runners/playwright.js +37 -14
- package/engine/qa-runners.js +9 -10
- package/engine/qa-runs.js +124 -11
- package/engine/qa-sessions.js +139 -40
- package/engine/quarantine-refs.js +81 -11
- package/engine/queries.js +163 -9
- package/engine/review-learning-backfill.js +482 -0
- package/engine/review-learning.js +1236 -0
- package/engine/runtimes/claude.js +60 -2
- package/engine/runtimes/codex.js +57 -16
- package/engine/runtimes/contract.js +21 -0
- package/engine/runtimes/copilot.js +51 -3
- package/engine/runtimes/index.js +1 -0
- package/engine/scheduler.js +201 -32
- package/engine/settings-validation.js +909 -0
- package/engine/shared.js +705 -208
- package/engine/spawn-agent.js +48 -21
- package/engine/steering-constraints.js +31 -0
- package/engine/steering-store.js +10 -2
- package/engine/steering.js +14 -2
- package/engine/timeout.js +404 -111
- package/engine/untrusted-fence.js +24 -10
- package/engine/watch-actions.js +7 -2
- package/engine/watches.js +353 -51
- package/engine/work-item-validation.js +561 -0
- package/engine/work-items-store.js +61 -1
- package/engine/worktree-gc.js +656 -37
- package/engine/worktree-preflight.js +154 -0
- package/engine.js +2068 -342
- package/package.json +1 -1
- package/playbooks/_pr-description-audit.md +88 -41
- package/playbooks/build-fix-complex.md +3 -3
- package/playbooks/docs.md +2 -2
- package/playbooks/fix.md +18 -18
- package/playbooks/implement.md +18 -18
- package/playbooks/qa-session-draft.md +10 -12
- package/playbooks/qa-session-execute.md +16 -12
- package/playbooks/qa-validate.md +8 -7
- package/playbooks/shared-rules.md +45 -0
- package/playbooks/test.md +4 -0
- package/prompts/cc-system.md +1 -1
- package/skills/check-self-authored-review-comment/SKILL.md +2 -2
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
const fs = require('fs');
|
|
15
15
|
const os = require('os');
|
|
16
16
|
const path = require('path');
|
|
17
|
-
const { FAILURE_CLASS, safeWrite, ts, resolveEngineCacheDir } = require('../shared');
|
|
17
|
+
const { FAILURE_CLASS, safeWrite, ts, resolveEngineCacheDir, staleResumeSessionCwdReason } = require('../shared');
|
|
18
18
|
const { wrapUntrusted, buildSource } = require('../untrusted-fence');
|
|
19
19
|
|
|
20
20
|
const ENGINE_DIR = __dirname.replace(/[\\/]runtimes$/, '');
|
|
@@ -317,6 +317,22 @@ function getResumeSessionId({ agentId, branchName, agentsDir, cwd = null, homeDi
|
|
|
317
317
|
return null;
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
// Stale-workdir invalidation (W-ms0vk9nv000z985b). The session is anchored
|
|
321
|
+
// to the directory it was created in; if the engine has since removed,
|
|
322
|
+
// recycled or quarantined that worktree, `--resume` dies before it writes
|
|
323
|
+
// any stdout. Checked BEFORE the jsonl probe below: it is the more
|
|
324
|
+
// specific diagnosis (and the jsonl probe would otherwise absorb the same
|
|
325
|
+
// case under a misleading "died before checkpoint" message, because the
|
|
326
|
+
// jsonl is keyed by the CURRENT cwd, not the session's).
|
|
327
|
+
const staleCwdReason = staleResumeSessionCwdReason(sessionFile.cwd, cwd);
|
|
328
|
+
if (staleCwdReason) {
|
|
329
|
+
if (logger && typeof logger.info === 'function') {
|
|
330
|
+
logger.info(`Skipping resume for ${agentId}: ${staleCwdReason} — clearing session.json`);
|
|
331
|
+
}
|
|
332
|
+
try { fs.unlinkSync(sessionPath); } catch {}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
320
336
|
// Stale-resume-target invalidation (W-mouugzow00068741). Claude only writes
|
|
321
337
|
// the jsonl on stable checkpoint; a session ID stamped before checkpoint
|
|
322
338
|
// dies with the agent. Probe for the jsonl and clear session.json when
|
|
@@ -348,7 +364,7 @@ function getResumeSessionId({ agentId, branchName, agentsDir, cwd = null, homeDi
|
|
|
348
364
|
return null;
|
|
349
365
|
}
|
|
350
366
|
|
|
351
|
-
function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now = ts, writeJson = safeWrite, logger = console } = {}) {
|
|
367
|
+
function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, cwd = null, now = ts, writeJson = safeWrite, logger = console } = {}) {
|
|
352
368
|
if (!sessionId || !agentId || agentId.startsWith('temp-') || !agentsDir) return false;
|
|
353
369
|
try {
|
|
354
370
|
writeJson(path.join(agentsDir, agentId, 'session.json'), {
|
|
@@ -356,6 +372,9 @@ function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now =
|
|
|
356
372
|
dispatchId,
|
|
357
373
|
savedAt: typeof now === 'function' ? now() : new Date().toISOString(),
|
|
358
374
|
branch: branch || null,
|
|
375
|
+
// Working directory the session was created in — read back by
|
|
376
|
+
// getResumeSessionId to refuse resuming into a dead/other worktree.
|
|
377
|
+
cwd: cwd || null,
|
|
359
378
|
runtime: RUNTIME_NAME,
|
|
360
379
|
});
|
|
361
380
|
return true;
|
|
@@ -526,6 +545,42 @@ function parseStreamChunk(line) {
|
|
|
526
545
|
try { return JSON.parse(trimmed); } catch { return null; }
|
|
527
546
|
}
|
|
528
547
|
|
|
548
|
+
// W-ms0vk9nv000z985b — did the process die between a tool start and its
|
|
549
|
+
// result? See engine/runtimes/contract.js#hasUnfinishedToolCall.
|
|
550
|
+
//
|
|
551
|
+
// Claude's stream-json brackets a tool call as an assistant `tool_use` block
|
|
552
|
+
// followed by a user `tool_result` block. A `result` event means the turn
|
|
553
|
+
// terminated normally, so any outstanding block was reconciled by the CLI
|
|
554
|
+
// itself and the turn was NOT truncated.
|
|
555
|
+
function hasUnfinishedToolCall(raw) {
|
|
556
|
+
const safeRaw = raw == null ? '' : String(raw);
|
|
557
|
+
if (!safeRaw) return false;
|
|
558
|
+
let started = 0;
|
|
559
|
+
let completed = 0;
|
|
560
|
+
const visit = (obj) => {
|
|
561
|
+
if (!obj || typeof obj !== 'object') return false;
|
|
562
|
+
if (obj.type === 'result') return true;
|
|
563
|
+
const content = obj.message?.content;
|
|
564
|
+
if (Array.isArray(content)) {
|
|
565
|
+
for (const block of content) {
|
|
566
|
+
if (block?.type === 'tool_use') started++;
|
|
567
|
+
else if (block?.type === 'tool_result') completed++;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return false;
|
|
571
|
+
};
|
|
572
|
+
for (const rawLine of safeRaw.split('\n')) {
|
|
573
|
+
const line = rawLine.trim();
|
|
574
|
+
if (!line || (!line.startsWith('{') && !line.startsWith('['))) continue;
|
|
575
|
+
let parsed;
|
|
576
|
+
try { parsed = JSON.parse(line); } catch { continue; }
|
|
577
|
+
for (const obj of Array.isArray(parsed) ? parsed : [parsed]) {
|
|
578
|
+
if (visit(obj)) return false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return started > completed;
|
|
582
|
+
}
|
|
583
|
+
|
|
529
584
|
// ── Error Normalization ──────────────────────────────────────────────────────
|
|
530
585
|
|
|
531
586
|
// Pull a model id out of an Anthropic-style invalid-model error string. Claude
|
|
@@ -848,6 +903,8 @@ const capabilities = {
|
|
|
848
903
|
// (proven empirically in spike P-3f8a1c92). Engine code gates on this flag,
|
|
849
904
|
// never on runtime.name.
|
|
850
905
|
imageInput: true,
|
|
906
|
+
// Claude natively discovers `.claude/skills/` directories without configuration
|
|
907
|
+
skillDiscovery: true,
|
|
851
908
|
};
|
|
852
909
|
|
|
853
910
|
// Install hint surfaced when `resolveBinary()` returns null. Consumed by
|
|
@@ -1042,6 +1099,7 @@ module.exports = {
|
|
|
1042
1099
|
modelLooksFamiliar,
|
|
1043
1100
|
parseOutput,
|
|
1044
1101
|
parseStreamChunk,
|
|
1102
|
+
hasUnfinishedToolCall,
|
|
1045
1103
|
parseError,
|
|
1046
1104
|
createStreamConsumer,
|
|
1047
1105
|
permissionBypassFlags: PERMISSION_BYPASS_FLAGS,
|
package/engine/runtimes/codex.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
safeWrite,
|
|
17
17
|
ts,
|
|
18
18
|
resolveEngineCacheDir,
|
|
19
|
+
staleResumeSessionCwdReason,
|
|
19
20
|
resolveCodexCcSandbox,
|
|
20
21
|
} = require('../shared');
|
|
21
22
|
|
|
@@ -455,7 +456,7 @@ function getMcpConfigPaths({ homeDir = os.homedir(), project = null } = {}) {
|
|
|
455
456
|
return [];
|
|
456
457
|
}
|
|
457
458
|
|
|
458
|
-
function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
|
|
459
|
+
function getResumeSessionId({ agentId, branchName, agentsDir, cwd = null, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
|
|
459
460
|
if (!agentId || agentId.startsWith('temp-') || !agentsDir) return null;
|
|
460
461
|
const sessionPath = path.join(agentsDir, agentId, 'session.json');
|
|
461
462
|
try {
|
|
@@ -468,6 +469,17 @@ function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60
|
|
|
468
469
|
try { fs.unlinkSync(sessionPath); } catch {}
|
|
469
470
|
return null;
|
|
470
471
|
}
|
|
472
|
+
// Stale-workdir invalidation (W-ms0vk9nv000z985b) — see engine/shared.js
|
|
473
|
+
// staleResumeSessionCwdReason. A session anchored to a worktree the engine
|
|
474
|
+
// has since removed/recycled cannot be resumed.
|
|
475
|
+
const staleCwdReason = staleResumeSessionCwdReason(sessionFile.cwd, cwd);
|
|
476
|
+
if (staleCwdReason) {
|
|
477
|
+
if (logger && typeof logger.info === 'function') {
|
|
478
|
+
logger.info(`Skipping resume for ${agentId}: ${staleCwdReason} - clearing session.json`);
|
|
479
|
+
}
|
|
480
|
+
try { fs.unlinkSync(sessionPath); } catch {}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
471
483
|
const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
|
|
472
484
|
const sameBranch = branchName && sessionFile.branch && sessionFile.branch === branchName;
|
|
473
485
|
if (sessionAge < maxAgeMs && sameBranch) return sessionFile.sessionId;
|
|
@@ -477,7 +489,7 @@ function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60
|
|
|
477
489
|
return null;
|
|
478
490
|
}
|
|
479
491
|
|
|
480
|
-
function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now = ts, writeJson = safeWrite, logger = console } = {}) {
|
|
492
|
+
function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, cwd = null, now = ts, writeJson = safeWrite, logger = console } = {}) {
|
|
481
493
|
if (!sessionId || !agentId || agentId.startsWith('temp-') || !agentsDir) return false;
|
|
482
494
|
try {
|
|
483
495
|
writeJson(path.join(agentsDir, agentId, 'session.json'), {
|
|
@@ -485,6 +497,9 @@ function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now =
|
|
|
485
497
|
dispatchId,
|
|
486
498
|
savedAt: typeof now === 'function' ? now() : new Date().toISOString(),
|
|
487
499
|
branch: branch || null,
|
|
500
|
+
// Working directory the session was created in — read back by
|
|
501
|
+
// getResumeSessionId to refuse resuming into a dead/other worktree.
|
|
502
|
+
cwd: cwd || null,
|
|
488
503
|
runtime: RUNTIME_NAME,
|
|
489
504
|
});
|
|
490
505
|
return true;
|
|
@@ -703,6 +718,45 @@ function parseStreamChunk(line) {
|
|
|
703
718
|
try { return JSON.parse(trimmed); } catch { return null; }
|
|
704
719
|
}
|
|
705
720
|
|
|
721
|
+
// W-ms0vk9nv000z985b — did the process die between a tool start and its
|
|
722
|
+
// completion? See engine/runtimes/contract.js#hasUnfinishedToolCall.
|
|
723
|
+
//
|
|
724
|
+
// Codex streams tool activity as per-item events (`item.started` /
|
|
725
|
+
// `item.updated` / `item.completed`), so outstanding calls are tracked by item
|
|
726
|
+
// id where one is supplied — that makes repeated `item.updated` events for the
|
|
727
|
+
// same call idempotent. A turn-level terminal (`turn.completed`, `result`,
|
|
728
|
+
// `task_complete`, …) means the turn ended normally and nothing was truncated.
|
|
729
|
+
function hasUnfinishedToolCall(raw) {
|
|
730
|
+
const safeRaw = raw == null ? '' : String(raw);
|
|
731
|
+
if (!safeRaw) return false;
|
|
732
|
+
const pending = new Set();
|
|
733
|
+
let anonymousStarted = 0;
|
|
734
|
+
let anonymousCompleted = 0;
|
|
735
|
+
for (const rawLine of safeRaw.split('\n')) {
|
|
736
|
+
const line = rawLine.trim();
|
|
737
|
+
if (!line || !line.startsWith('{')) continue;
|
|
738
|
+
let obj;
|
|
739
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
740
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
741
|
+
const type = _eventType(obj);
|
|
742
|
+
if (_isTurnTerminalType(type)) return false;
|
|
743
|
+
const tool = _extractToolUse(obj);
|
|
744
|
+
if (!tool) continue;
|
|
745
|
+
const finished = tool.status === 'completed'
|
|
746
|
+
|| tool.status === 'failed'
|
|
747
|
+
|| /[._](?:completed|complete|end|ended|finished)$/i.test(type);
|
|
748
|
+
if (tool.id) {
|
|
749
|
+
if (finished) pending.delete(tool.id);
|
|
750
|
+
else pending.add(tool.id);
|
|
751
|
+
} else if (finished) {
|
|
752
|
+
anonymousCompleted++;
|
|
753
|
+
} else {
|
|
754
|
+
anonymousStarted++;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return pending.size > 0 || anonymousStarted > anonymousCompleted;
|
|
758
|
+
}
|
|
759
|
+
|
|
706
760
|
function _collectErrorSignal(rawOutput) {
|
|
707
761
|
const text = rawOutput == null ? '' : String(rawOutput);
|
|
708
762
|
if (!text) return '';
|
|
@@ -880,20 +934,6 @@ function getModelCacheKey({ env = process.env, timeoutMs = 10000 } = {}) {
|
|
|
880
934
|
}
|
|
881
935
|
}
|
|
882
936
|
|
|
883
|
-
function getModelCacheKey({ env = process.env, timeoutMs = 10000 } = {}) {
|
|
884
|
-
const resolved = resolveBinary({ env });
|
|
885
|
-
if (!resolved) return null;
|
|
886
|
-
try {
|
|
887
|
-
return _execFileCapture(resolved.bin, [...(resolved.leadingArgs || []), '--version'], {
|
|
888
|
-
env,
|
|
889
|
-
timeoutMs,
|
|
890
|
-
native: resolved.native,
|
|
891
|
-
}).trim() || null;
|
|
892
|
-
} catch {
|
|
893
|
-
return null;
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
|
|
897
937
|
function createStreamConsumer(ctx) {
|
|
898
938
|
let deltaText = '';
|
|
899
939
|
let terminalText = '';
|
|
@@ -1003,6 +1043,7 @@ module.exports = {
|
|
|
1003
1043
|
modelLooksFamiliar,
|
|
1004
1044
|
parseOutput,
|
|
1005
1045
|
parseStreamChunk,
|
|
1046
|
+
hasUnfinishedToolCall,
|
|
1006
1047
|
parseError,
|
|
1007
1048
|
createStreamConsumer,
|
|
1008
1049
|
permissionBypassFlags: PERMISSION_BYPASS_FLAGS,
|
|
@@ -198,6 +198,26 @@ function shouldSuppressPostMutationError(runtime, context = {}) {
|
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Did this runtime's raw output end with a tool call that was started but never
|
|
203
|
+
* completed? (W-ms0vk9nv000z985b)
|
|
204
|
+
*
|
|
205
|
+
* Optional adapter hook. Only the adapter knows its own event vocabulary, so
|
|
206
|
+
* the detection lives there; orchestration asks this question without any
|
|
207
|
+
* knowledge of event names or runtime identity.
|
|
208
|
+
*
|
|
209
|
+
* Fails safe to `false`: an adapter that does not implement the hook (or one
|
|
210
|
+
* that throws) yields the pre-existing classification, never a new failure.
|
|
211
|
+
*/
|
|
212
|
+
function hasUnfinishedToolCall(runtime, raw) {
|
|
213
|
+
if (!runtime || typeof runtime.hasUnfinishedToolCall !== 'function') return false;
|
|
214
|
+
try {
|
|
215
|
+
return runtime.hasUnfinishedToolCall(raw) === true;
|
|
216
|
+
} catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
201
221
|
function buildWorkerArgs(runtime, options = {}) {
|
|
202
222
|
if (runtime?.capabilities?.acpWorkerPool !== true) {
|
|
203
223
|
throw new Error(`Runtime "${runtime?.name || '<unknown>'}" does not support persistent ACP workers`);
|
|
@@ -234,6 +254,7 @@ module.exports = {
|
|
|
234
254
|
prepareWorkspace,
|
|
235
255
|
isSpawnStartupEvent,
|
|
236
256
|
shouldSuppressPostMutationError,
|
|
257
|
+
hasUnfinishedToolCall,
|
|
237
258
|
buildWorkerArgs,
|
|
238
259
|
encodePooledOutput,
|
|
239
260
|
};
|
|
@@ -32,7 +32,7 @@ const https = require('https');
|
|
|
32
32
|
const os = require('os');
|
|
33
33
|
const path = require('path');
|
|
34
34
|
const { execSync } = require('child_process');
|
|
35
|
-
const { ENGINE_DEFAULTS, FAILURE_CLASS, safeWrite, ts, resolveEngineCacheDir } = require('../shared');
|
|
35
|
+
const { ENGINE_DEFAULTS, FAILURE_CLASS, safeWrite, ts, resolveEngineCacheDir, staleResumeSessionCwdReason } = require('../shared');
|
|
36
36
|
|
|
37
37
|
const ENGINE_DIR = __dirname.replace(/[\\/]runtimes$/, '');
|
|
38
38
|
const _CACHE_DIR = resolveEngineCacheDir(ENGINE_DIR);
|
|
@@ -639,7 +639,7 @@ function encodePooledOutput(event = {}) {
|
|
|
639
639
|
// RUNTIME_NAME is stamped into every session.json so the pre-spawn resume path
|
|
640
640
|
// can reject session IDs produced by another adapter before invoking the CLI.
|
|
641
641
|
|
|
642
|
-
function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
|
|
642
|
+
function getResumeSessionId({ agentId, branchName, agentsDir, cwd = null, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
|
|
643
643
|
if (!agentId || agentId.startsWith('temp-') || !agentsDir) return null;
|
|
644
644
|
const sessionPath = path.join(agentsDir, agentId, 'session.json');
|
|
645
645
|
try {
|
|
@@ -659,6 +659,21 @@ function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60
|
|
|
659
659
|
return null;
|
|
660
660
|
}
|
|
661
661
|
|
|
662
|
+
// Stale-workdir invalidation (W-ms0vk9nv000z985b). Copilot auto-cds into
|
|
663
|
+
// the session's persisted cwd on resume; when the engine has removed or
|
|
664
|
+
// recycled that worktree the CLI logs `resume-auto-cd: ignoring persisted
|
|
665
|
+
// cwd ... missing or not a directory` and exits 1 with EMPTY stdout, which
|
|
666
|
+
// the engine could only classify as a meaningless `empty-output` failure.
|
|
667
|
+
// Refuse the resume up front and clear session.json so the retry is fresh.
|
|
668
|
+
const staleCwdReason = staleResumeSessionCwdReason(sessionFile.cwd, cwd);
|
|
669
|
+
if (staleCwdReason) {
|
|
670
|
+
if (logger && typeof logger.info === 'function') {
|
|
671
|
+
logger.info(`Skipping resume for ${agentId}: ${staleCwdReason} — clearing session.json`);
|
|
672
|
+
}
|
|
673
|
+
try { fs.unlinkSync(sessionPath); } catch {}
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
662
677
|
const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
|
|
663
678
|
const sameBranch = branchName && sessionFile.branch && sessionFile.branch === branchName;
|
|
664
679
|
if (sessionAge < maxAgeMs && sameBranch) {
|
|
@@ -673,7 +688,7 @@ function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60
|
|
|
673
688
|
return null;
|
|
674
689
|
}
|
|
675
690
|
|
|
676
|
-
function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now = ts, writeJson = safeWrite, logger = console } = {}) {
|
|
691
|
+
function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, cwd = null, now = ts, writeJson = safeWrite, logger = console } = {}) {
|
|
677
692
|
if (!sessionId || !agentId || agentId.startsWith('temp-') || !agentsDir) return false;
|
|
678
693
|
try {
|
|
679
694
|
writeJson(path.join(agentsDir, agentId, 'session.json'), {
|
|
@@ -681,6 +696,9 @@ function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now =
|
|
|
681
696
|
dispatchId,
|
|
682
697
|
savedAt: typeof now === 'function' ? now() : new Date().toISOString(),
|
|
683
698
|
branch: branch || null,
|
|
699
|
+
// Working directory the session was created in — read back by
|
|
700
|
+
// getResumeSessionId to refuse resuming into a dead/other worktree.
|
|
701
|
+
cwd: cwd || null,
|
|
684
702
|
runtime: RUNTIME_NAME,
|
|
685
703
|
});
|
|
686
704
|
return true;
|
|
@@ -921,6 +939,35 @@ function parseStreamChunk(line) {
|
|
|
921
939
|
return obj;
|
|
922
940
|
}
|
|
923
941
|
|
|
942
|
+
// W-ms0vk9nv000z985b — did the process die between a tool start and its
|
|
943
|
+
// completion?
|
|
944
|
+
//
|
|
945
|
+
// Copilot brackets every tool invocation with `tool.execution_start` and
|
|
946
|
+
// `tool.execution_complete`. When the CLI exits 0 in the middle of a long tool
|
|
947
|
+
// call (the observed incident: exit right after starting `npm test`), the
|
|
948
|
+
// start event has no matching completion. Counting is sufficient and
|
|
949
|
+
// order-independent within a single sequential stream — no id correlation
|
|
950
|
+
// needed, and a stream that never invoked a tool trivially reports false.
|
|
951
|
+
//
|
|
952
|
+
// `task_complete` is excluded: it is the terminal report tool, not workload.
|
|
953
|
+
function hasUnfinishedToolCall(raw) {
|
|
954
|
+
const safeRaw = raw == null ? '' : String(raw);
|
|
955
|
+
if (!safeRaw) return false;
|
|
956
|
+
let started = 0;
|
|
957
|
+
let completed = 0;
|
|
958
|
+
for (const rawLine of safeRaw.split('\n')) {
|
|
959
|
+
const line = rawLine.trim();
|
|
960
|
+
if (!line || !line.startsWith('{')) continue;
|
|
961
|
+
let obj;
|
|
962
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
963
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
964
|
+
if (obj.data?.toolName === 'task_complete') continue;
|
|
965
|
+
if (obj.type === 'tool.execution_start') started++;
|
|
966
|
+
else if (obj.type === 'tool.execution_complete') completed++;
|
|
967
|
+
}
|
|
968
|
+
return started > completed;
|
|
969
|
+
}
|
|
970
|
+
|
|
924
971
|
// ── Error Normalization ─────────────────────────────────────────────────────
|
|
925
972
|
|
|
926
973
|
// In-memory model-discovery cache used by parseError's invalid-model branch
|
|
@@ -1528,6 +1575,7 @@ module.exports = {
|
|
|
1528
1575
|
modelLooksFamiliar,
|
|
1529
1576
|
parseOutput,
|
|
1530
1577
|
parseStreamChunk,
|
|
1578
|
+
hasUnfinishedToolCall,
|
|
1531
1579
|
parseError,
|
|
1532
1580
|
createStreamConsumer,
|
|
1533
1581
|
isSpawnStartupEvent,
|
package/engine/runtimes/index.js
CHANGED
|
@@ -82,6 +82,7 @@ module.exports = {
|
|
|
82
82
|
prepareWorkspace: contract.prepareWorkspace,
|
|
83
83
|
isSpawnStartupEvent: contract.isSpawnStartupEvent,
|
|
84
84
|
shouldSuppressPostMutationError: contract.shouldSuppressPostMutationError,
|
|
85
|
+
hasUnfinishedToolCall: contract.hasUnfinishedToolCall,
|
|
85
86
|
buildWorkerArgs: contract.buildWorkerArgs,
|
|
86
87
|
encodePooledOutput: contract.encodePooledOutput,
|
|
87
88
|
validateRuntimeAdapter: contract.normalizeRuntimeAdapter,
|
package/engine/scheduler.js
CHANGED
|
@@ -25,7 +25,141 @@ const fs = require('fs');
|
|
|
25
25
|
const shared = require('./shared');
|
|
26
26
|
const routing = require('./routing');
|
|
27
27
|
const harness = require('./harness');
|
|
28
|
-
const { safeJson, safeWrite, mutateJsonFileLocked, mutateScheduleRuns, ts, dateStamp, log, WI_STATUS, WORK_TYPE } = shared;
|
|
28
|
+
const { safeJson, safeWrite, mutateJsonFileLocked, mutateScheduleRuns, ts, dateStamp, log, WI_STATUS, WORK_TYPE, WORK_ITEM_PRIORITY } = shared;
|
|
29
|
+
const apiValidation = require('./api-validation');
|
|
30
|
+
|
|
31
|
+
const SCHEDULE_LIMITS = Object.freeze({
|
|
32
|
+
id: 64,
|
|
33
|
+
cron: 64,
|
|
34
|
+
title: 300,
|
|
35
|
+
description: 20000,
|
|
36
|
+
naturalText: 2000,
|
|
37
|
+
participants: 50,
|
|
38
|
+
});
|
|
39
|
+
const SCHEDULE_WORK_TYPES = Object.freeze(Object.values(WORK_TYPE));
|
|
40
|
+
const SCHEDULE_ID_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/;
|
|
41
|
+
|
|
42
|
+
function _scheduleInputError(message, options = {}) {
|
|
43
|
+
throw new apiValidation.ApiInputError(message, {
|
|
44
|
+
code: options.code || 'invalid-schedule',
|
|
45
|
+
path: options.path,
|
|
46
|
+
rejectedValue: options.rejectedValue,
|
|
47
|
+
allowedValues: options.allowedValues,
|
|
48
|
+
expected: options.expected,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _validateConfiguredProject(project, config, pathName = 'project') {
|
|
53
|
+
if (project === undefined || project === null || project === '') return;
|
|
54
|
+
apiValidation.validateString(project, {
|
|
55
|
+
path: pathName,
|
|
56
|
+
allowEmpty: false,
|
|
57
|
+
maxLength: 128,
|
|
58
|
+
});
|
|
59
|
+
if (project.trim().toLowerCase() === 'central') return;
|
|
60
|
+
const result = shared.resolveConfiguredProject(project, config || {});
|
|
61
|
+
if (result.error) {
|
|
62
|
+
_scheduleInputError(result.error, {
|
|
63
|
+
code: 'invalid-project',
|
|
64
|
+
path: pathName,
|
|
65
|
+
rejectedValue: project,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function _validateConfiguredAgent(agent, config, pathName = 'agent', options = {}) {
|
|
71
|
+
if (agent === undefined || agent === null || agent === '') {
|
|
72
|
+
if (options.required === true) {
|
|
73
|
+
_scheduleInputError(`${pathName} must identify a configured agent`, {
|
|
74
|
+
code: 'invalid-agent',
|
|
75
|
+
path: pathName,
|
|
76
|
+
rejectedValue: agent,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
apiValidation.validateString(agent, {
|
|
82
|
+
path: pathName,
|
|
83
|
+
allowEmpty: false,
|
|
84
|
+
maxLength: 128,
|
|
85
|
+
});
|
|
86
|
+
const agents = Object.keys(config?.agents || {});
|
|
87
|
+
if (!agents.includes(agent)) {
|
|
88
|
+
_scheduleInputError(`Unknown agent "${agent}". Known agents: ${agents.join(', ') || '(none)'}`, {
|
|
89
|
+
code: 'invalid-agent',
|
|
90
|
+
path: pathName,
|
|
91
|
+
rejectedValue: agent,
|
|
92
|
+
allowedValues: agents,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validateScheduleIdentifier(id, pathName = 'id') {
|
|
98
|
+
const validated = apiValidation.validateIdentifier(id, {
|
|
99
|
+
path: pathName,
|
|
100
|
+
maxLength: SCHEDULE_LIMITS.id,
|
|
101
|
+
pattern: SCHEDULE_ID_PATTERN,
|
|
102
|
+
});
|
|
103
|
+
if (validated === 'prototype'
|
|
104
|
+
|| Object.prototype.hasOwnProperty.call(Object.prototype, validated)) {
|
|
105
|
+
_scheduleInputError(`${pathName} must not use a prototype-sensitive name`, {
|
|
106
|
+
code: 'invalid-identifier',
|
|
107
|
+
path: pathName,
|
|
108
|
+
rejectedValue: id,
|
|
109
|
+
expected: 'safe identifier',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return validated;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function _validateSchedulePriority(priority, pathName = 'priority') {
|
|
116
|
+
const error = shared.validateWorkItemPriority(priority);
|
|
117
|
+
if (!error) return;
|
|
118
|
+
_scheduleInputError(error.error, {
|
|
119
|
+
code: error.code,
|
|
120
|
+
path: pathName,
|
|
121
|
+
rejectedValue: error.rejectedValue,
|
|
122
|
+
allowedValues: error.allowedValues,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateScheduleDefinition(schedule, options = {}) {
|
|
127
|
+
apiValidation.validatePlainObject(schedule, { path: options.path || 'schedule' });
|
|
128
|
+
validateScheduleIdentifier(schedule.id);
|
|
129
|
+
validateCronExpression(schedule.cron, { path: 'cron' });
|
|
130
|
+
apiValidation.validateString(schedule.title, {
|
|
131
|
+
path: 'title',
|
|
132
|
+
allowEmpty: false,
|
|
133
|
+
maxLength: SCHEDULE_LIMITS.title,
|
|
134
|
+
});
|
|
135
|
+
apiValidation.validateString(schedule.description, {
|
|
136
|
+
path: 'description',
|
|
137
|
+
optional: true,
|
|
138
|
+
maxLength: SCHEDULE_LIMITS.description,
|
|
139
|
+
});
|
|
140
|
+
apiValidation.validateEnum(schedule.type, {
|
|
141
|
+
path: 'type',
|
|
142
|
+
optional: true,
|
|
143
|
+
values: SCHEDULE_WORK_TYPES,
|
|
144
|
+
});
|
|
145
|
+
_validateSchedulePriority(schedule.priority);
|
|
146
|
+
_validateConfiguredProject(schedule.project, options.config, 'project');
|
|
147
|
+
_validateConfiguredAgent(schedule.agent, options.config, 'agent');
|
|
148
|
+
for (const field of ['enabled', 'agentLock', 'hardAgent']) {
|
|
149
|
+
apiValidation.validateBoolean(schedule[field], { path: field, optional: true });
|
|
150
|
+
}
|
|
151
|
+
if (schedule.type === WORK_TYPE.MEETING || schedule.participants !== undefined) {
|
|
152
|
+
apiValidation.validateArray(schedule.participants, {
|
|
153
|
+
path: 'participants',
|
|
154
|
+
minLength: 1,
|
|
155
|
+
maxLength: SCHEDULE_LIMITS.participants,
|
|
156
|
+
itemValidator(participant, ctx) {
|
|
157
|
+
_validateConfiguredAgent(participant, options.config, ctx.path, { required: true });
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return schedule;
|
|
162
|
+
}
|
|
29
163
|
|
|
30
164
|
/**
|
|
31
165
|
* Compute the ISO 8601 week of the year for `date` as `YYYY-Www` (e.g.
|
|
@@ -218,44 +352,68 @@ function applyScheduleTemplateVarsRecursive(obj, extraVars) {
|
|
|
218
352
|
// every Date when any field is out of range, so the schedule never fires.
|
|
219
353
|
// This catches typos like minute=99, hour=24, dow=9 that today are accepted
|
|
220
354
|
// as exact-value matchers and silently never trigger.
|
|
221
|
-
function
|
|
222
|
-
field
|
|
355
|
+
function _strictCronFieldMatcher(field, min, max) {
|
|
356
|
+
if (typeof field !== 'string' || !field) return null;
|
|
223
357
|
if (field === '*') return () => true;
|
|
224
358
|
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
// A step larger than max either matches only val=0 (e.g., */60 for minute)
|
|
230
|
-
// or nothing meaningful — treat as never-fires for predictability.
|
|
231
|
-
if (field.startsWith('*/')) {
|
|
232
|
-
const step = parseInt(field.slice(2), 10);
|
|
233
|
-
if (isNaN(step) || step <= 0) return () => false;
|
|
234
|
-
if (hasMax && step > max) return () => false;
|
|
359
|
+
const stepMatch = field.match(/^\*\/(\d+)$/);
|
|
360
|
+
if (stepMatch) {
|
|
361
|
+
const step = Number(stepMatch[1]);
|
|
362
|
+
if (!Number.isSafeInteger(step) || step <= 0 || step > max) return null;
|
|
235
363
|
return (val) => val % step === 0;
|
|
236
364
|
}
|
|
237
365
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
366
|
+
const values = new Set();
|
|
367
|
+
const tokens = field.split(',');
|
|
368
|
+
if (tokens.length === 0 || tokens.some(token => !token)) return null;
|
|
369
|
+
for (const token of tokens) {
|
|
370
|
+
const range = token.match(/^(\d+)-(\d+)$/);
|
|
371
|
+
if (range) {
|
|
372
|
+
const start = Number(range[1]);
|
|
373
|
+
const end = Number(range[2]);
|
|
374
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)
|
|
375
|
+
|| start < min || end > max || start > end) return null;
|
|
376
|
+
for (let value = start; value <= end; value += 1) values.add(value);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (!/^\d+$/.test(token)) return null;
|
|
380
|
+
const exact = Number(token);
|
|
381
|
+
if (!Number.isSafeInteger(exact) || exact < min || exact > max) return null;
|
|
382
|
+
values.add(exact);
|
|
249
383
|
}
|
|
384
|
+
return (val) => values.has(val);
|
|
385
|
+
}
|
|
250
386
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return (val) => val === exact;
|
|
256
|
-
}
|
|
387
|
+
function parseCronField(field, min, max) {
|
|
388
|
+
return _strictCronFieldMatcher(typeof field === 'string' ? field.trim() : field, min, max)
|
|
389
|
+
|| (() => false);
|
|
390
|
+
}
|
|
257
391
|
|
|
258
|
-
|
|
392
|
+
function validateCronExpression(expression, options = {}) {
|
|
393
|
+
const pathName = options.path || 'cron';
|
|
394
|
+
const normalized = apiValidation.validateString(expression, {
|
|
395
|
+
path: pathName,
|
|
396
|
+
trim: true,
|
|
397
|
+
allowEmpty: false,
|
|
398
|
+
maxLength: SCHEDULE_LIMITS.cron,
|
|
399
|
+
});
|
|
400
|
+
const parts = normalized.split(/\s+/);
|
|
401
|
+
const valid = parts.length === 3
|
|
402
|
+
&& _strictCronFieldMatcher(parts[0], 0, 59)
|
|
403
|
+
&& _strictCronFieldMatcher(parts[1], 0, 23)
|
|
404
|
+
&& _strictCronFieldMatcher(parts[2], 0, 6);
|
|
405
|
+
if (!valid) {
|
|
406
|
+
_scheduleInputError(
|
|
407
|
+
`${pathName} must be a valid three-field cron expression (minute hour dayOfWeek)`,
|
|
408
|
+
{
|
|
409
|
+
code: 'invalid-cron',
|
|
410
|
+
path: pathName,
|
|
411
|
+
rejectedValue: expression,
|
|
412
|
+
expected: 'three-field cron',
|
|
413
|
+
},
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return normalized;
|
|
259
417
|
}
|
|
260
418
|
|
|
261
419
|
// Parse a 3-field cron expression: "minute hour dayOfWeek"
|
|
@@ -332,7 +490,7 @@ function createScheduledWorkItem(sched, extraVars) {
|
|
|
332
490
|
id: workItemId,
|
|
333
491
|
title: sched.title,
|
|
334
492
|
type: routing.normalizeWorkType(sched.type, WORK_TYPE.IMPLEMENT),
|
|
335
|
-
priority: sched.priority ||
|
|
493
|
+
priority: sched.priority || WORK_ITEM_PRIORITY.MEDIUM,
|
|
336
494
|
description: sched.description || sched.title,
|
|
337
495
|
status: WI_STATUS.PENDING,
|
|
338
496
|
created: ts(),
|
|
@@ -398,6 +556,12 @@ function discoverScheduledWork(config) {
|
|
|
398
556
|
for (const sched of schedules) {
|
|
399
557
|
if (!sched.id || !sched.cron || !sched.title) continue;
|
|
400
558
|
if (sched.enabled === false) continue; // strict false check — undefined/null default to enabled per schema
|
|
559
|
+
try {
|
|
560
|
+
validateScheduleDefinition(sched, { config });
|
|
561
|
+
} catch (error) {
|
|
562
|
+
log('warn', `Scheduler: invalid schedule ${sched.id || '(missing id)'} — skipping (${error.message})`);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
401
565
|
|
|
402
566
|
// Backward compat: runs[sched.id] can be a string (old format) or object (new format)
|
|
403
567
|
const runEntry = runs[sched.id] || null;
|
|
@@ -474,6 +638,11 @@ function discoverScheduledWork(config) {
|
|
|
474
638
|
}
|
|
475
639
|
|
|
476
640
|
module.exports = {
|
|
641
|
+
SCHEDULE_LIMITS,
|
|
642
|
+
SCHEDULE_WORK_TYPES,
|
|
643
|
+
validateCronExpression,
|
|
644
|
+
validateScheduleIdentifier,
|
|
645
|
+
validateScheduleDefinition,
|
|
477
646
|
parseCronExpr,
|
|
478
647
|
parseCronField,
|
|
479
648
|
shouldRunNow,
|