@yemi33/minions 0.1.1952 → 0.1.1954
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/engine/cli.js +16 -0
- package/engine/kb-sweep.js +86 -0
- package/engine/watches.js +177 -13
- package/engine.js +13 -6
- package/package.json +1 -1
package/engine/cli.js
CHANGED
|
@@ -772,6 +772,22 @@ const commands = {
|
|
|
772
772
|
}
|
|
773
773
|
})();
|
|
774
774
|
|
|
775
|
+
// W-mp7gox8w000n8936 — Boot reconcile for kb-sweep state: clear stale
|
|
776
|
+
// `in-flight`/`starting` records left over from a crashed runner (or a
|
|
777
|
+
// legacy pre-pid runner). Without this, the record sits there clogging
|
|
778
|
+
// /api/knowledge/sweep/status until someone POSTs a new sweep. Idempotent.
|
|
779
|
+
(function startupReconcileKbSweep() {
|
|
780
|
+
try {
|
|
781
|
+
const { reconcileSweepStateOnBoot } = require('./kb-sweep');
|
|
782
|
+
const stats = reconcileSweepStateOnBoot();
|
|
783
|
+
if (stats.released > 0) {
|
|
784
|
+
console.log(` KB-sweep boot reconcile: released stale ${stats.prevStatus} record (pid=${stats.prevPid}, reason=${stats.reason})`);
|
|
785
|
+
}
|
|
786
|
+
} catch (err) {
|
|
787
|
+
e.log('warn', `KB-sweep boot reconcile failed: ${err.message}`);
|
|
788
|
+
}
|
|
789
|
+
})();
|
|
790
|
+
|
|
775
791
|
// Initial tick
|
|
776
792
|
e.tick();
|
|
777
793
|
|
package/engine/kb-sweep.js
CHANGED
|
@@ -339,6 +339,91 @@ function readSweepLiveness(opts = {}) {
|
|
|
339
339
|
};
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* One-shot boot-time reconciliation for `engine/kb-sweep-state.json`.
|
|
344
|
+
*
|
|
345
|
+
* Mirrors the worktree-pool / keep-process boot reconcilers in `engine/cli.js`:
|
|
346
|
+
* after an engine restart we may inherit a stale `in-flight`/`starting` record
|
|
347
|
+
* whose runner has long since died (or — for legacy state files — never
|
|
348
|
+
* recorded a pid). Without proactive cleanup the record sits there clogging
|
|
349
|
+
* `/api/knowledge/sweep/status` until someone POSTs a new sweep (which the
|
|
350
|
+
* dashboard's stale-guard would then auto-release).
|
|
351
|
+
*
|
|
352
|
+
* Behavior:
|
|
353
|
+
* - Absent state file → no-op.
|
|
354
|
+
* - Terminal status (completed/failed) → no-op.
|
|
355
|
+
* - `starting` within 15s boot-grace → no-op (matches readSweepLiveness).
|
|
356
|
+
* - `in-flight` / stale `starting` with a live pid → no-op (running sweep).
|
|
357
|
+
* - Otherwise rewrite to `status: 'failed'` preserving the original pid for
|
|
358
|
+
* forensics. The original record fields are kept; `reconciliationReason`
|
|
359
|
+
* records why we released it.
|
|
360
|
+
*
|
|
361
|
+
* CAS guard: re-reads state immediately before the write and aborts if any of
|
|
362
|
+
* status/startedAt/pid/sweepToken changed since the snapshot — protects against
|
|
363
|
+
* a concurrent dashboard POST or runner that wrote a fresh record between our
|
|
364
|
+
* decision and our write.
|
|
365
|
+
*
|
|
366
|
+
* @param {object} [opts]
|
|
367
|
+
* @param {number} [opts.now=Date.now()] injectable clock (tests)
|
|
368
|
+
* @param {(pid:number)=>boolean} [opts.isPidAlive] injectable (tests)
|
|
369
|
+
* @returns {{ scanned:number, released:number, reason?:string,
|
|
370
|
+
* prevStatus?:string, prevPid?:number }}
|
|
371
|
+
*/
|
|
372
|
+
function reconcileSweepStateOnBoot(opts = {}) {
|
|
373
|
+
const now = Number(opts.now) || Date.now();
|
|
374
|
+
const isPidAlive = typeof opts.isPidAlive === 'function'
|
|
375
|
+
? opts.isPidAlive
|
|
376
|
+
: (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
377
|
+
const state = safeJson(KB_SWEEP_STATE_PATH);
|
|
378
|
+
if (!state) return { scanned: 0, released: 0 };
|
|
379
|
+
if (state.status !== 'in-flight' && state.status !== 'starting') {
|
|
380
|
+
return { scanned: 1, released: 0, reason: `terminal-status-${state.status}` };
|
|
381
|
+
}
|
|
382
|
+
if (state.status === 'starting') {
|
|
383
|
+
const STARTING_GRACE_MS = 15000;
|
|
384
|
+
const age = state.startedAt ? now - Number(state.startedAt) : Infinity;
|
|
385
|
+
if (age <= STARTING_GRACE_MS) {
|
|
386
|
+
return { scanned: 1, released: 0, reason: 'within-boot-grace' };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const pid = Number(state.pid) || 0;
|
|
390
|
+
const alive = pid > 0 ? !!isPidAlive(pid) : false;
|
|
391
|
+
if (alive) {
|
|
392
|
+
return { scanned: 1, released: 0, reason: 'pid-still-alive', prevPid: pid };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const reason = pid > 0 ? 'pid-dead-on-startup-check' : 'no-pid-recorded-on-startup-check';
|
|
396
|
+
|
|
397
|
+
// CAS guard: re-read right before the write so we don't clobber a fresh
|
|
398
|
+
// `starting`/`in-flight` record that a concurrent dashboard POST or runner
|
|
399
|
+
// wrote between our decision and our write.
|
|
400
|
+
const current = safeJson(KB_SWEEP_STATE_PATH);
|
|
401
|
+
if (!current
|
|
402
|
+
|| current.status !== state.status
|
|
403
|
+
|| Number(current.startedAt || 0) !== Number(state.startedAt || 0)
|
|
404
|
+
|| Number(current.pid || 0) !== pid
|
|
405
|
+
|| (current.sweepToken || null) !== (state.sweepToken || null)) {
|
|
406
|
+
return { scanned: 1, released: 0, reason: 'state-changed-before-reconcile' };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const failedState = {
|
|
410
|
+
...state,
|
|
411
|
+
status: 'failed',
|
|
412
|
+
completedAt: now,
|
|
413
|
+
completedAtIso: new Date(now).toISOString(),
|
|
414
|
+
error: `sweep abandoned: ${reason}`,
|
|
415
|
+
reconciliationReason: reason,
|
|
416
|
+
};
|
|
417
|
+
// Direct safeWrite (NOT _writeSweepState) so the original pid is preserved
|
|
418
|
+
// for forensics — _writeSweepState would overwrite it with this process's
|
|
419
|
+
// pid, destroying the diagnostic value of the record.
|
|
420
|
+
try { safeWrite(KB_SWEEP_STATE_PATH, JSON.stringify(failedState)); } catch { /* ignore */ }
|
|
421
|
+
return {
|
|
422
|
+
scanned: 1, released: 1, reason,
|
|
423
|
+
prevStatus: state.status, prevPid: pid,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
342
427
|
/**
|
|
343
428
|
* Run the full sweep. Returns a rich summary.
|
|
344
429
|
*
|
|
@@ -480,6 +565,7 @@ module.exports = {
|
|
|
480
565
|
runKbSweep,
|
|
481
566
|
staleGuardMs,
|
|
482
567
|
readSweepLiveness,
|
|
568
|
+
reconcileSweepStateOnBoot,
|
|
483
569
|
KB_SWEEP_STATE_PATH,
|
|
484
570
|
KB_SWEEP_LOG_PATH,
|
|
485
571
|
KB_SWEEP_RUNNER_PATH,
|
package/engine/watches.js
CHANGED
|
@@ -9,18 +9,62 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Target-type behavior is data-driven via a registry — see TARGET_TYPES below.
|
|
11
11
|
* Each registered target type provides:
|
|
12
|
-
* - label:
|
|
13
|
-
* - conditions:
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* - label: human-friendly name shown in dashboard pickers
|
|
13
|
+
* - conditions: array of condition keys this target type accepts
|
|
14
|
+
* - absoluteConditions: (optional) array of condition keys that should
|
|
15
|
+
* fire-once when stopAfter=0 (compound state assertions
|
|
16
|
+
* like "merged" / "completed"). Normalized into a Set
|
|
17
|
+
* on the spec at registration time.
|
|
18
|
+
* - fetchEntity: (target, state) => entity-or-null lookup
|
|
19
|
+
* - captureState: (entity, prevState) => snapshot for change-detection
|
|
20
|
+
* - evaluate: (condition, entity, prevState, target) =>
|
|
21
|
+
* { triggered, message }
|
|
22
|
+
* - contextVars: (optional) (entity, prevState, newState) =>
|
|
23
|
+
* object of extra template vars merged into the
|
|
24
|
+
* action ctx after buildTriggerContext. Used by
|
|
25
|
+
* plugins to surface fields that aren't auto-promoted
|
|
26
|
+
* from newState (e.g. {{prevExtracted}}) or to alias
|
|
27
|
+
* scalars under nicer names.
|
|
17
28
|
*
|
|
18
29
|
* Built-in types: pr, work-item, meeting, plan, schedule, pipeline, dispatch,
|
|
19
30
|
* agent. Additional types can be added at runtime via registerTargetType().
|
|
20
31
|
*
|
|
32
|
+
* Canonical plugin example: `watches.d/http.js` (W-mp7i22mu00191b07) — a
|
|
33
|
+
* generic HTTP poller that demonstrates the full plugin contract including
|
|
34
|
+
* `contextVars` (exposing `{{httpStatus}}`, `{{extracted}}`, `{{prevExtracted}}`,
|
|
35
|
+
* etc. to action templates) and a background-fetch cache (because the engine
|
|
36
|
+
* tick path is synchronous; plugins that need async I/O maintain their own
|
|
37
|
+
* cache and return cached entities from the synchronous fetchEntity).
|
|
38
|
+
*
|
|
39
|
+
* ── Plugin folder (`watches.d/`) — W-mp7hg58e000b5212 ────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* At engine boot, every `*.js` file in `<MINIONS_DIR>/watches.d/` is loaded
|
|
42
|
+
* and its exports are passed to registerTargetType(). This lets users add
|
|
43
|
+
* new watch target types without editing engine source.
|
|
44
|
+
*
|
|
45
|
+
* Each plugin file exports either:
|
|
46
|
+
*
|
|
47
|
+
* module.exports = {
|
|
48
|
+
* name: 'gh-workflow-run',
|
|
49
|
+
* spec: { label, conditions, absoluteConditions?, fetchEntity, captureState, evaluate, ... },
|
|
50
|
+
* };
|
|
51
|
+
*
|
|
52
|
+
* or an array of those objects for multi-target plugins.
|
|
53
|
+
*
|
|
54
|
+
* Resolution: `path.join(MINIONS_DIR, 'watches.d')` so it works in both dev
|
|
55
|
+
* (D:/squad) and installed (~/.minions) layouts. A missing folder is a silent
|
|
56
|
+
* no-op (not an error). Each file is required inside its own try/catch — one
|
|
57
|
+
* bad plugin must not break boot or block other plugins. Successful loads
|
|
58
|
+
* log at INFO; registration failures log at WARN with the file path.
|
|
59
|
+
*
|
|
60
|
+
* Security: plugins are user-trusted JS (same trust level as `playbooks/` and
|
|
61
|
+
* `pipelines/`); no sandboxing. Hot-reload is not supported — restart the
|
|
62
|
+
* engine to pick up new plugin files (matches how playbooks already work).
|
|
63
|
+
*
|
|
21
64
|
* State stored in engine/watches.json — concurrency-safe via mutateJsonFileLocked.
|
|
22
65
|
*/
|
|
23
66
|
|
|
67
|
+
const fs = require('fs');
|
|
24
68
|
const path = require('path');
|
|
25
69
|
const shared = require('./shared');
|
|
26
70
|
const { safeJsonArr, mutateJsonFileLocked, ts, uid, log, writeToInbox,
|
|
@@ -59,12 +103,23 @@ const TARGET_TYPES = {};
|
|
|
59
103
|
/**
|
|
60
104
|
* Register a watch target type.
|
|
61
105
|
* @param {string} type - Unique key (e.g. 'pr', 'meeting', 'custom-thing')
|
|
62
|
-
* @param {object} spec - { label, conditions, fetchEntity, captureState, evaluate }
|
|
63
|
-
* - label:
|
|
64
|
-
* - conditions:
|
|
65
|
-
* -
|
|
66
|
-
*
|
|
67
|
-
*
|
|
106
|
+
* @param {object} spec - { label, conditions, absoluteConditions?, fetchEntity, captureState, evaluate }
|
|
107
|
+
* - label: string shown in dashboard pickers
|
|
108
|
+
* - conditions: array of condition strings the type accepts
|
|
109
|
+
* - absoluteConditions: (optional) array of condition keys that should fire
|
|
110
|
+
* once and auto-expire when stopAfter=0. Normalized
|
|
111
|
+
* into a Set on the spec; defaults to an empty Set.
|
|
112
|
+
* Each entry MUST also appear in `conditions`.
|
|
113
|
+
* - fetchEntity: (target, state) => entity-or-null
|
|
114
|
+
* - captureState: (entity, prevState) => snapshot object
|
|
115
|
+
* - evaluate: (condition, entity, prevState, target) =>
|
|
116
|
+
* { triggered, message }
|
|
117
|
+
* - contextVars: (optional) (entity, prevState, newState) =>
|
|
118
|
+
* object whose keys are merged into the action ctx
|
|
119
|
+
* after buildTriggerContext. Use to expose plugin-
|
|
120
|
+
* specific template vars that aren't auto-promoted
|
|
121
|
+
* scalars from newState (e.g. previousState scalars
|
|
122
|
+
* like {{prevExtracted}}). Errors are logged + swallowed.
|
|
68
123
|
*/
|
|
69
124
|
function registerTargetType(type, spec) {
|
|
70
125
|
if (!type || typeof type !== 'string') throw new Error('registerTargetType: type must be a non-empty string');
|
|
@@ -75,7 +130,26 @@ function registerTargetType(type, spec) {
|
|
|
75
130
|
if (!Array.isArray(spec.conditions) || spec.conditions.length === 0) {
|
|
76
131
|
throw new Error(`registerTargetType(${type}): spec.conditions must be a non-empty array`);
|
|
77
132
|
}
|
|
78
|
-
|
|
133
|
+
// W-mp7hg58e000b5212 — normalize absoluteConditions into a Set for fast
|
|
134
|
+
// .has() lookup at evaluate-time. Accepts an array (declarative spec form)
|
|
135
|
+
// or pre-built Set; defaults to empty when omitted. Entries must be a
|
|
136
|
+
// subset of `conditions` so a typo here doesn't silently no-op.
|
|
137
|
+
let absoluteSet;
|
|
138
|
+
if (spec.absoluteConditions === undefined || spec.absoluteConditions === null) {
|
|
139
|
+
absoluteSet = new Set();
|
|
140
|
+
} else if (spec.absoluteConditions instanceof Set) {
|
|
141
|
+
absoluteSet = new Set(spec.absoluteConditions);
|
|
142
|
+
} else if (Array.isArray(spec.absoluteConditions)) {
|
|
143
|
+
absoluteSet = new Set(spec.absoluteConditions);
|
|
144
|
+
} else {
|
|
145
|
+
throw new Error(`registerTargetType(${type}): spec.absoluteConditions must be an array or Set`);
|
|
146
|
+
}
|
|
147
|
+
for (const c of absoluteSet) {
|
|
148
|
+
if (!spec.conditions.includes(c)) {
|
|
149
|
+
throw new Error(`registerTargetType(${type}): absoluteConditions entry '${c}' is not in conditions[]`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
TARGET_TYPES[type] = { ...spec, absoluteConditions: absoluteSet };
|
|
79
153
|
}
|
|
80
154
|
|
|
81
155
|
/** Returns the registered spec for a target type, or null. */
|
|
@@ -420,7 +494,14 @@ function checkWatches(config, state) {
|
|
|
420
494
|
// Expire when stopAfter > 0 and trigger count reaches the limit.
|
|
421
495
|
// Absolute conditions (merged, build-pass, etc.) auto-expire on first trigger
|
|
422
496
|
// when stopAfter=0 — fire-once semantics. Change-based conditions run forever.
|
|
423
|
-
|
|
497
|
+
// W-mp7hg58e000b5212 — resolves through the per-target-type
|
|
498
|
+
// absoluteConditions Set (declared in each spec); falls back to the
|
|
499
|
+
// legacy WATCH_ABSOLUTE_CONDITIONS union only when the watch's
|
|
500
|
+
// targetType has been unregistered between create and check (defensive).
|
|
501
|
+
const _absSpec = TARGET_TYPES[watch.targetType];
|
|
502
|
+
const isAbsolute = _absSpec
|
|
503
|
+
? _absSpec.absoluteConditions.has(watch.condition)
|
|
504
|
+
: WATCH_ABSOLUTE_CONDITIONS.has(watch.condition);
|
|
424
505
|
if ((watch.stopAfter > 0 && watch.triggerCount >= watch.stopAfter) ||
|
|
425
506
|
(watch.stopAfter === 0 && isAbsolute)) {
|
|
426
507
|
watch.status = WATCH_STATUS.EXPIRED;
|
|
@@ -485,6 +566,18 @@ async function _runActionTask(task) {
|
|
|
485
566
|
entity: task.entity,
|
|
486
567
|
message: task.message,
|
|
487
568
|
});
|
|
569
|
+
// W-mp7i22mu00191b07 — let the registered target type contribute extra
|
|
570
|
+
// template vars (e.g. http plugin exposes {{httpStatus}}, {{prevExtracted}}).
|
|
571
|
+
// Failures are swallowed so a misbehaving plugin can't poison action dispatch.
|
|
572
|
+
const tt = TARGET_TYPES[task.snapshot.targetType];
|
|
573
|
+
if (tt && typeof tt.contextVars === 'function') {
|
|
574
|
+
try {
|
|
575
|
+
const extras = tt.contextVars(task.entity, task.previousState, task.newState) || {};
|
|
576
|
+
if (extras && typeof extras === 'object') Object.assign(ctx, extras);
|
|
577
|
+
} catch (err) {
|
|
578
|
+
log('warn', `${task.snapshot.targetType} contextVars failed: ${err.message}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
488
581
|
const result = await watchActions.runWatchAction(task.snapshot, ctx);
|
|
489
582
|
const isChain = Array.isArray(task.snapshot.action);
|
|
490
583
|
const actionLabel = isChain
|
|
@@ -591,6 +684,12 @@ registerTargetType(WATCH_TARGET_TYPE.PR, {
|
|
|
591
684
|
WATCH_CONDITION.READY_FOR_MERGE, WATCH_CONDITION.BEHIND_MASTER,
|
|
592
685
|
WATCH_CONDITION.DRAFT_FLIPPED,
|
|
593
686
|
],
|
|
687
|
+
// W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
|
|
688
|
+
// Mirrors what shared.WATCH_ABSOLUTE_CONDITIONS used to centralize for PR.
|
|
689
|
+
absoluteConditions: [
|
|
690
|
+
WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
|
|
691
|
+
WATCH_CONDITION.READY_FOR_MERGE,
|
|
692
|
+
],
|
|
594
693
|
fetchEntity: (target, state) => findPrByTarget(state.pullRequests, target),
|
|
595
694
|
captureState: (pr) => ({
|
|
596
695
|
status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus,
|
|
@@ -708,6 +807,11 @@ registerTargetType(WATCH_TARGET_TYPE.WORK_ITEM, {
|
|
|
708
807
|
WATCH_CONDITION.STALLED, WATCH_CONDITION.RETRY_LIMIT_REACHED,
|
|
709
808
|
WATCH_CONDITION.DEPENDENCY_MET,
|
|
710
809
|
],
|
|
810
|
+
// W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
|
|
811
|
+
absoluteConditions: [
|
|
812
|
+
WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
|
|
813
|
+
WATCH_CONDITION.RETRY_LIMIT_REACHED,
|
|
814
|
+
],
|
|
711
815
|
fetchEntity: (target, state) => (state.workItems || []).find(w => w.id === target) || null,
|
|
712
816
|
// P-w2c8d1e7 — Phase 1.2: surface retry count, pending-reason, and branch
|
|
713
817
|
// for upcoming stalled / branch-aware predicates. `retries` aliases the
|
|
@@ -797,6 +901,7 @@ registerTargetType(WATCH_TARGET_TYPE.MEETING, {
|
|
|
797
901
|
label: 'Meeting',
|
|
798
902
|
description: 'Watch a meeting for conclusion or status changes',
|
|
799
903
|
conditions: [WATCH_CONDITION.CONCLUDED, WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY],
|
|
904
|
+
absoluteConditions: [WATCH_CONDITION.CONCLUDED],
|
|
800
905
|
fetchEntity: (target, state) => (state.meetings || []).find(m => m && m.id === target) || null,
|
|
801
906
|
captureState: (m) => ({ status: m.status }),
|
|
802
907
|
evaluate: (condition, m, prevState, target) => {
|
|
@@ -866,6 +971,11 @@ registerTargetType(WATCH_TARGET_TYPE.PLAN, {
|
|
|
866
971
|
// from the PRD's missing_features array.
|
|
867
972
|
WATCH_CONDITION.ALL_ITEMS_DONE, WATCH_CONDITION.ITEM_FAILED_N_TIMES,
|
|
868
973
|
],
|
|
974
|
+
// W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
|
|
975
|
+
absoluteConditions: [
|
|
976
|
+
WATCH_CONDITION.APPROVED, WATCH_CONDITION.REJECTED, WATCH_CONDITION.COMPLETED,
|
|
977
|
+
WATCH_CONDITION.ALL_ITEMS_DONE, WATCH_CONDITION.ITEM_FAILED_N_TIMES,
|
|
978
|
+
],
|
|
869
979
|
fetchEntity: _findPlan,
|
|
870
980
|
// P-w2c8d1e7 — Phase 1.2: surface item progress derived from the PRD's
|
|
871
981
|
// missing_features array so plan-level predicates (e.g. all-items-done)
|
|
@@ -941,6 +1051,9 @@ registerTargetType(WATCH_TARGET_TYPE.SCHEDULE, {
|
|
|
941
1051
|
WATCH_CONDITION.RAN, WATCH_CONDITION.ENABLED, WATCH_CONDITION.DISABLED,
|
|
942
1052
|
WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
|
|
943
1053
|
],
|
|
1054
|
+
// W-mp7hg58e000b5212 — schedule has no absolute conditions today; all of
|
|
1055
|
+
// its keywords are change-based (RAN/ENABLED/DISABLED detect transitions).
|
|
1056
|
+
absoluteConditions: [],
|
|
944
1057
|
fetchEntity: _findSchedule,
|
|
945
1058
|
// P-w2c8d1e7 — Phase 1.2: surface next_run as null. Cron parsing in
|
|
946
1059
|
// engine/scheduler.js exposes parseCronExpr/.matches() but not a
|
|
@@ -1024,6 +1137,8 @@ registerTargetType(WATCH_TARGET_TYPE.PIPELINE, {
|
|
|
1024
1137
|
// counter computed inside captureState.
|
|
1025
1138
|
WATCH_CONDITION.STAGE_ADVANCED, WATCH_CONDITION.STUCK_IN_STAGE,
|
|
1026
1139
|
],
|
|
1140
|
+
// W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
|
|
1141
|
+
absoluteConditions: [WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED],
|
|
1027
1142
|
fetchEntity: _findPipelineLatestRun,
|
|
1028
1143
|
// P-w2c8d1e7 — Phase 1.2: surface current_stage_id (first non-terminal
|
|
1029
1144
|
// stage in declaration order) for stage-targeted predicates.
|
|
@@ -1116,6 +1231,8 @@ registerTargetType(WATCH_TARGET_TYPE.DISPATCH, {
|
|
|
1116
1231
|
WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED,
|
|
1117
1232
|
WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
|
|
1118
1233
|
],
|
|
1234
|
+
// W-mp7hg58e000b5212 — per-target absolute (fire-once when stopAfter=0).
|
|
1235
|
+
absoluteConditions: [WATCH_CONDITION.COMPLETED, WATCH_CONDITION.FAILED],
|
|
1119
1236
|
fetchEntity: _findDispatchEntry,
|
|
1120
1237
|
captureState: (e) => ({ status: e.status || e._list || null, list: e._list || null }),
|
|
1121
1238
|
evaluate: (condition, e, prevState, target) => {
|
|
@@ -1157,6 +1274,9 @@ registerTargetType(WATCH_TARGET_TYPE.AGENT, {
|
|
|
1157
1274
|
conditions: [
|
|
1158
1275
|
WATCH_CONDITION.ACTIVITY_CHANGE, WATCH_CONDITION.STATUS_CHANGE, WATCH_CONDITION.ANY,
|
|
1159
1276
|
],
|
|
1277
|
+
// W-mp7hg58e000b5212 — agent has no absolute conditions today; all of its
|
|
1278
|
+
// keywords are change-based.
|
|
1279
|
+
absoluteConditions: [],
|
|
1160
1280
|
fetchEntity: _findAgent,
|
|
1161
1281
|
// P-w2c8d1e7 — Phase 1.2: surface currentDispatchId so predicates can
|
|
1162
1282
|
// distinguish "agent now working on a different dispatch" from a plain
|
|
@@ -1190,6 +1310,49 @@ registerTargetType(WATCH_TARGET_TYPE.AGENT, {
|
|
|
1190
1310
|
},
|
|
1191
1311
|
});
|
|
1192
1312
|
|
|
1313
|
+
// ── Plugin folder discovery (W-mp7hg58e000b5212) ─────────────────────────────
|
|
1314
|
+
// Auto-load `<MINIONS_DIR>/watches.d/*.js` at module load time, AFTER the 8
|
|
1315
|
+
// built-in registerTargetType() calls so plugins can override built-ins by
|
|
1316
|
+
// re-using their key (last-write-wins). Per the README block at the top of
|
|
1317
|
+
// this file: missing folder is silent, per-file try/catch isolates failures,
|
|
1318
|
+
// and INFO/WARN logs trace what loaded.
|
|
1319
|
+
function _loadPluginTargetTypes() {
|
|
1320
|
+
const dir = path.join(shared.MINIONS_DIR, 'watches.d');
|
|
1321
|
+
let entries;
|
|
1322
|
+
try {
|
|
1323
|
+
entries = fs.readdirSync(dir);
|
|
1324
|
+
} catch (err) {
|
|
1325
|
+
if (err && err.code === 'ENOENT') return; // silent no-op
|
|
1326
|
+
log('warn', `watches.d/ readdir failed: ${err.message}`);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
for (const fname of entries.sort()) {
|
|
1330
|
+
if (!fname.endsWith('.js')) continue;
|
|
1331
|
+
const fp = path.join(dir, fname);
|
|
1332
|
+
try {
|
|
1333
|
+
// Bust cache so re-loads (tests, future hot-reload) pick up file edits.
|
|
1334
|
+
try { delete require.cache[require.resolve(fp)]; } catch {}
|
|
1335
|
+
const mod = require(fp);
|
|
1336
|
+
const list = Array.isArray(mod) ? mod : [mod];
|
|
1337
|
+
for (const entry of list) {
|
|
1338
|
+
if (!entry || !entry.name || !entry.spec) {
|
|
1339
|
+
log('warn', `watches.d/${fname}: export missing { name, spec } — skipping entry`);
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
try {
|
|
1343
|
+
registerTargetType(entry.name, entry.spec);
|
|
1344
|
+
log('info', `Loaded watch target type '${entry.name}' from watches.d/${fname}`);
|
|
1345
|
+
} catch (regErr) {
|
|
1346
|
+
log('warn', `watches.d/${fname}: registerTargetType('${entry.name}') failed: ${regErr.message}`);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
} catch (err) {
|
|
1350
|
+
log('warn', `watches.d/${fname}: load failed: ${err.message}`);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
_loadPluginTargetTypes();
|
|
1355
|
+
|
|
1193
1356
|
module.exports = {
|
|
1194
1357
|
DEFAULT_WATCH_INTERVAL,
|
|
1195
1358
|
getWatches,
|
|
@@ -1214,4 +1377,5 @@ module.exports = {
|
|
|
1214
1377
|
_watchesPath, // exported for testing — dynamic, respects MINIONS_TEST_DIR
|
|
1215
1378
|
_runActionTask, // exported for testing — invoked by checkWatches per fired action
|
|
1216
1379
|
_TARGET_TYPES: TARGET_TYPES, // exported for testing — direct registry access
|
|
1380
|
+
_loadPluginTargetTypes, // exported for testing — re-scans watches.d/
|
|
1217
1381
|
};
|
package/engine.js
CHANGED
|
@@ -910,6 +910,19 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
910
910
|
};
|
|
911
911
|
_phaseT.afterPrompt = Date.now();
|
|
912
912
|
|
|
913
|
+
if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type) && !isPipelineBranchName(branchName)) {
|
|
914
|
+
// W-mp7havqf0007ce6b: read-only types (meeting/ask/explore/plan/plan-to-prd)
|
|
915
|
+
// short-circuit BEFORE the worktree-creation block. resolveSpawnPaths returns
|
|
916
|
+
// worktreeRootDir=null for read-only types, and path.resolve(null, ...) throws
|
|
917
|
+
// ("paths[0] must be of type string. Received null"). Pipeline branches are
|
|
918
|
+
// exempt — they always need a worktree (the worktree IS the pipeline workspace),
|
|
919
|
+
// and the recompute at lines ~806-824 ensures worktreeRootDir is non-null
|
|
920
|
+
// before the worktree-creation block runs for them.
|
|
921
|
+
log('info', `${type}: read-only task with branch ${branchName} — skipping worktree, running in cwd ${cwd}`);
|
|
922
|
+
branchName = null;
|
|
923
|
+
worktreePath = null;
|
|
924
|
+
}
|
|
925
|
+
|
|
913
926
|
if (branchName) {
|
|
914
927
|
updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up worktree for branch ${branchName}`);
|
|
915
928
|
const wtDirName = shared.buildWorktreeDirName({
|
|
@@ -949,12 +962,6 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
949
962
|
_phaseT.reuseSyncStart = Date.now();
|
|
950
963
|
await syncReusedWorktree(rootDir, existingWt, branchName, _gitOpts);
|
|
951
964
|
_phaseT.reuseSyncEnd = Date.now();
|
|
952
|
-
} else if (READ_ONLY_ROOT_TASK_TYPES.has(type) && !isPipelineBranchName(branchName)) {
|
|
953
|
-
// Read-only tasks — no worktree needed, run in cwd from resolveSpawnPaths
|
|
954
|
-
// (project.localPath or MINIONS_DIR). W-mp73x32w000l143d.
|
|
955
|
-
log('info', `${type}: read-only task, no worktree needed — running in cwd ${cwd}`);
|
|
956
|
-
branchName = null;
|
|
957
|
-
worktreePath = null;
|
|
958
965
|
} else {
|
|
959
966
|
_phaseT.createWorktreeStart = Date.now();
|
|
960
967
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1954",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|