pactive 0.0.2-alpha.1 → 0.0.4-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opcteam-plugin/plugin.json +8 -4
- package/BREAKING-CHANGES.md +14 -3
- package/README.md +86 -6
- package/bin/pactive.mjs +917 -4
- package/commands/pactive.md +4 -1
- package/dsh/index.js +214 -6
- package/mcp/tools/definitions.mjs +205 -8
- package/package.json +1 -1
- package/pi/extension.ts +246 -8
- package/pi/skills/pactive/SKILL.md +57 -20
- package/skills/pactive/SKILL.md +57 -20
- package/src/core/assistants.mjs +237 -0
- package/src/core/config.mjs +40 -3
- package/src/core/fleet.mjs +80 -0
- package/src/core/guard.mjs +117 -32
- package/src/core/idle.mjs +11 -8
- package/src/core/index.mjs +3 -0
- package/src/core/scheduler.mjs +16 -4
- package/src/core/state.mjs +134 -12
- package/src/core/tick.mjs +128 -0
- package/src/hosts/herdr/fleet-view.mjs +31 -0
- package/src/hosts/herdr/index.mjs +429 -0
package/src/core/scheduler.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultAssistantId } from './assistants.mjs';
|
|
1
2
|
import { pactiveError } from './errors.mjs';
|
|
2
3
|
|
|
3
4
|
export const TASK_KINDS = ['in', 'at', 'every'];
|
|
@@ -28,26 +29,34 @@ export const schedule = {
|
|
|
28
29
|
if (everyMs <= 0) throw pactiveError('TASK_INTERVAL_REQUIRED', 'an "every" task needs a positive interval');
|
|
29
30
|
dueAt = ts + everyMs;
|
|
30
31
|
} else {
|
|
32
|
+
// A relative delay may arrive either as a duration (delayMs/afterMs/ms) or already
|
|
33
|
+
// resolved to an absolute time (spec.at — what every host-surface parseWhen produces).
|
|
31
34
|
const delay = Number.isFinite(spec.delayMs) ? spec.delayMs
|
|
32
35
|
: Number.isFinite(spec.afterMs) ? spec.afterMs
|
|
33
|
-
: Number.isFinite(spec.ms) ? spec.ms :
|
|
34
|
-
dueAt = ts + delay;
|
|
36
|
+
: Number.isFinite(spec.ms) ? spec.ms : null;
|
|
37
|
+
dueAt = delay === null && Number.isFinite(spec.at) ? spec.at : ts + (delay ?? 0);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
const owner = spec.owner ?? state.identity?.ownerKey ?? '';
|
|
38
41
|
const id = spec.id ?? `task-${ts}-${(seq += 1)}`;
|
|
42
|
+
// Every task belongs to exactly one assistant (contract §2 / I65): an unqualified task lands
|
|
43
|
+
// on the default assistant, never on "whoever happens to be first".
|
|
44
|
+
const assistantId = spec.assistantId ?? defaultAssistantId(state.identity?.ownerKey ?? '');
|
|
39
45
|
|
|
40
46
|
const existing = state.tasks.find((t) => t.id === id && t.owner === owner && t.dueAt === dueAt && t.active !== false);
|
|
41
47
|
if (existing) return existing;
|
|
42
48
|
|
|
43
49
|
const task = {
|
|
44
50
|
id,
|
|
51
|
+
assistantId,
|
|
45
52
|
kind,
|
|
46
53
|
dueAt,
|
|
47
54
|
owner,
|
|
48
55
|
everyMs,
|
|
49
56
|
label: spec.label ?? null,
|
|
50
57
|
payload: spec.payload ?? null,
|
|
58
|
+
// The prompt is what the trigger injects later; dropping it registers an empty reminder.
|
|
59
|
+
prompt: spec.prompt ?? spec.content ?? null,
|
|
51
60
|
createdAt: ts,
|
|
52
61
|
lastRunAt: 0,
|
|
53
62
|
active: true,
|
|
@@ -64,8 +73,11 @@ export const schedule = {
|
|
|
64
73
|
return state.tasks.length !== before;
|
|
65
74
|
},
|
|
66
75
|
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
/** All tasks, or only one assistant's tasks when `assistantId` is given. */
|
|
77
|
+
list(state, assistantId) {
|
|
78
|
+
const all = Array.isArray(state.tasks) ? [...state.tasks] : [];
|
|
79
|
+
if (typeof assistantId !== 'string' || assistantId === '') return all;
|
|
80
|
+
return all.filter((t) => t && t.assistantId === assistantId);
|
|
69
81
|
},
|
|
70
82
|
|
|
71
83
|
/** Returns the tasks that are due now and consumes them (once for "in"/"at"). */
|
package/src/core/state.mjs
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_ASSISTANT_NAME,
|
|
6
|
+
defaultAssistantId,
|
|
7
|
+
makeAssistant,
|
|
8
|
+
normalizeAssistantTable,
|
|
9
|
+
} from './assistants.mjs';
|
|
4
10
|
import { pactiveError } from './errors.mjs';
|
|
5
11
|
import { ensureDir, withLock } from './lock.mjs';
|
|
6
12
|
import { ledger } from './lineage.mjs';
|
|
7
13
|
|
|
8
|
-
|
|
14
|
+
/** Schema v2 adds the assistant fleet (contract §2 / I61–I66). */
|
|
15
|
+
export const SCHEMA_VERSION = 2;
|
|
16
|
+
export const LEGACY_SCHEMA_VERSION = 1;
|
|
17
|
+
export const GLOBAL_HOURLY_LIMIT_DEFAULT = 24;
|
|
9
18
|
|
|
10
19
|
let tmpSeq = 0;
|
|
11
20
|
|
|
21
|
+
export function defaultGuard() {
|
|
22
|
+
return { hourWindowStart: 0, hourCount: 0, lastTriggerAt: 0, lastTriggerEpoch: -1 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function defaultGlobalGuard() {
|
|
26
|
+
return { hourWindowStart: 0, hourCount: 0, hourlyLimit: GLOBAL_HOURLY_LIMIT_DEFAULT };
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
export function defaultState() {
|
|
13
30
|
return {
|
|
14
31
|
schemaVersion: SCHEMA_VERSION,
|
|
@@ -27,9 +44,14 @@ export function defaultState() {
|
|
|
27
44
|
minIntervalMs: 600000,
|
|
28
45
|
cooldownMs: 1800000,
|
|
29
46
|
},
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
guard:
|
|
47
|
+
// `guard` stays the guard bucket of the `default` assistant (the schema-v1 shape is
|
|
48
|
+
// exactly one assistant's counters, so no migration shim is needed for it).
|
|
49
|
+
guard: defaultGuard(),
|
|
50
|
+
// Every named assistant gets its own bucket: A's cooldown must not throttle B (I62).
|
|
51
|
+
guardByAssistant: {},
|
|
52
|
+
// One fleet-wide ceiling no amount of assistants can bypass (I64).
|
|
53
|
+
globalGuard: defaultGlobalGuard(),
|
|
54
|
+
assistants: {},
|
|
33
55
|
tasks: [],
|
|
34
56
|
peers: {},
|
|
35
57
|
outbox: [],
|
|
@@ -78,6 +100,42 @@ export function ensureStateDirs(paths) {
|
|
|
78
100
|
return paths;
|
|
79
101
|
}
|
|
80
102
|
|
|
103
|
+
function normalizeGuard(raw, fallback) {
|
|
104
|
+
const guard = { ...fallback, ...(raw && typeof raw === 'object' ? raw : {}) };
|
|
105
|
+
if (!Number.isFinite(guard.hourWindowStart)) guard.hourWindowStart = 0;
|
|
106
|
+
if (!Number.isFinite(guard.hourCount)) guard.hourCount = 0;
|
|
107
|
+
if (!Number.isFinite(guard.lastTriggerAt)) guard.lastTriggerAt = 0;
|
|
108
|
+
if (!Number.isFinite(guard.lastTriggerEpoch)) guard.lastTriggerEpoch = -1;
|
|
109
|
+
return guard;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeGuardTable(raw) {
|
|
113
|
+
const out = {};
|
|
114
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
115
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
116
|
+
if (typeof key !== 'string' || key === '') continue;
|
|
117
|
+
out[key] = normalizeGuard(value, defaultGuard());
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeTasks(raw, fallbackAssistantId) {
|
|
123
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const task of list) {
|
|
126
|
+
// A non-object entry is dropped, but the caller can see the count change: the read path
|
|
127
|
+
// normalises rather than inventing fields on garbage.
|
|
128
|
+
if (!task || typeof task !== 'object') continue;
|
|
129
|
+
out.push({
|
|
130
|
+
...task,
|
|
131
|
+
assistantId: typeof task.assistantId === 'string' && task.assistantId
|
|
132
|
+
? task.assistantId
|
|
133
|
+
: fallbackAssistantId,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
81
139
|
function normalize(raw) {
|
|
82
140
|
const base = defaultState();
|
|
83
141
|
const state = { ...base, ...raw };
|
|
@@ -85,9 +143,13 @@ function normalize(raw) {
|
|
|
85
143
|
state.identity = { ...base.identity, ...(raw.identity || {}) };
|
|
86
144
|
state.idle = { ...base.idle, ...(raw.idle || {}) };
|
|
87
145
|
state.policy = { ...base.policy, ...(raw.policy || {}) };
|
|
88
|
-
state.guard =
|
|
89
|
-
|
|
90
|
-
state.
|
|
146
|
+
state.guard = normalizeGuard(raw.guard, base.guard);
|
|
147
|
+
state.guardByAssistant = normalizeGuardTable(raw.guardByAssistant);
|
|
148
|
+
state.globalGuard = { ...base.globalGuard, ...(raw.globalGuard || {}) };
|
|
149
|
+
if (!Number.isFinite(state.globalGuard.hourlyLimit)) state.globalGuard.hourlyLimit = GLOBAL_HOURLY_LIMIT_DEFAULT;
|
|
150
|
+
const ownerKey = typeof state.identity.ownerKey === 'string' ? state.identity.ownerKey : '';
|
|
151
|
+
state.assistants = normalizeAssistantTable(raw.assistants, ownerKey);
|
|
152
|
+
state.tasks = normalizeTasks(raw.tasks, defaultAssistantId(ownerKey));
|
|
91
153
|
state.peers = raw.peers && typeof raw.peers === 'object' ? raw.peers : {};
|
|
92
154
|
state.outbox = Array.isArray(raw.outbox) ? raw.outbox : [];
|
|
93
155
|
state.busy = raw.busy === true;
|
|
@@ -99,6 +161,57 @@ function normalize(raw) {
|
|
|
99
161
|
return state;
|
|
100
162
|
}
|
|
101
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Schema v1 → v2: one single-assistant state becomes the `default` assistant.
|
|
166
|
+
*
|
|
167
|
+
* Failure is explicit (I20): a v1 file whose tasks cannot be carried over must raise instead of
|
|
168
|
+
* being quarantined into an empty state, because "quarantine and start fresh" looks identical to
|
|
169
|
+
* "the user never had anything".
|
|
170
|
+
*/
|
|
171
|
+
export function migrateStateV1(raw, now = Date.now()) {
|
|
172
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
173
|
+
throw pactiveError('STATE_MIGRATION_FAILED', 'schema v1 state is not an object');
|
|
174
|
+
}
|
|
175
|
+
if (raw.tasks !== undefined && !Array.isArray(raw.tasks)) {
|
|
176
|
+
throw pactiveError('STATE_MIGRATION_FAILED', 'schema v1 state has a non-array tasks field; refusing to drop it');
|
|
177
|
+
}
|
|
178
|
+
const tasks = Array.isArray(raw.tasks) ? raw.tasks : [];
|
|
179
|
+
const ownerKey = typeof raw.identity?.ownerKey === 'string' ? raw.identity.ownerKey : '';
|
|
180
|
+
const id = defaultAssistantId(ownerKey);
|
|
181
|
+
const assistant = makeAssistant(
|
|
182
|
+
{
|
|
183
|
+
assistantId: id,
|
|
184
|
+
name: DEFAULT_ASSISTANT_NAME,
|
|
185
|
+
title: DEFAULT_ASSISTANT_NAME,
|
|
186
|
+
// I15: "installed" is not "enabled" — the migrated assistant inherits the old switch.
|
|
187
|
+
enabled: raw.idle?.enabled === true,
|
|
188
|
+
createdAt: Number.isFinite(raw.createdAt) ? raw.createdAt : now,
|
|
189
|
+
},
|
|
190
|
+
ownerKey,
|
|
191
|
+
now,
|
|
192
|
+
);
|
|
193
|
+
const migratedTasks = tasks.map((task) => {
|
|
194
|
+
if (!task || typeof task !== 'object') return task;
|
|
195
|
+
return { ...task, assistantId: id };
|
|
196
|
+
});
|
|
197
|
+
if (migratedTasks.length !== tasks.length) {
|
|
198
|
+
throw pactiveError('STATE_MIGRATION_FAILED', 'schema v1 tasks could not be carried over without loss');
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
...raw,
|
|
202
|
+
schemaVersion: SCHEMA_VERSION,
|
|
203
|
+
assistants: { [id]: assistant },
|
|
204
|
+
tasks: migratedTasks,
|
|
205
|
+
guardByAssistant: {},
|
|
206
|
+
globalGuard: defaultGlobalGuard(),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** True when the state file on disk still speaks schema v1 (the CLI reports it). */
|
|
211
|
+
export function needsMigration(parsed) {
|
|
212
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.schemaVersion === LEGACY_SCHEMA_VERSION;
|
|
213
|
+
}
|
|
214
|
+
|
|
102
215
|
export function loadState(target) {
|
|
103
216
|
const paths = toPaths(target);
|
|
104
217
|
let raw;
|
|
@@ -108,16 +221,25 @@ export function loadState(target) {
|
|
|
108
221
|
if (err.code === 'ENOENT') return defaultState();
|
|
109
222
|
throw err;
|
|
110
223
|
}
|
|
224
|
+
let parsed;
|
|
111
225
|
try {
|
|
112
|
-
|
|
113
|
-
if (!parsed || typeof parsed !== 'object' || parsed.schemaVersion !== SCHEMA_VERSION) {
|
|
114
|
-
throw pactiveError('STATE_SCHEMA_UNSUPPORTED', `unsupported schemaVersion in ${paths.stateFile}`);
|
|
115
|
-
}
|
|
116
|
-
return normalize(parsed);
|
|
226
|
+
parsed = JSON.parse(raw);
|
|
117
227
|
} catch (err) {
|
|
118
228
|
quarantine(paths, err);
|
|
119
229
|
return defaultState();
|
|
120
230
|
}
|
|
231
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
232
|
+
quarantine(paths, { code: 'STATE_SCHEMA_UNSUPPORTED' });
|
|
233
|
+
return defaultState();
|
|
234
|
+
}
|
|
235
|
+
if (parsed.schemaVersion === SCHEMA_VERSION) return normalize(parsed);
|
|
236
|
+
if (needsMigration(parsed)) {
|
|
237
|
+
// Migration happens on read (read-only callers such as `status`/`doctor` never write);
|
|
238
|
+
// the next write persists v2 through saveState.
|
|
239
|
+
return normalize(migrateStateV1(parsed));
|
|
240
|
+
}
|
|
241
|
+
quarantine(paths, { code: 'STATE_SCHEMA_UNSUPPORTED' });
|
|
242
|
+
return defaultState();
|
|
121
243
|
}
|
|
122
244
|
|
|
123
245
|
function quarantine(paths, cause) {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { defaultAssistantId } from './assistants.mjs';
|
|
2
|
+
import { reserveTrigger } from './guard.mjs';
|
|
3
|
+
import { evaluateIdle } from './idle.mjs';
|
|
4
|
+
import { ledger } from './lineage.mjs';
|
|
5
|
+
|
|
6
|
+
/** Reasons that mean "this really was going to fire but the throttle held it back". */
|
|
7
|
+
export const THROTTLE_REASONS = ['quiet', 'hourly-limit', 'global-limit', 'min-interval', 'cooldown'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One drive round: due tasks + one idle sweep per enabled assistant,结果落 ledger。
|
|
11
|
+
*
|
|
12
|
+
* This is the minimal closed loop before a daemon exists: `pactive tick` runs it once, so the
|
|
13
|
+
* fleet is testable unattended. A blocked task stays active (retried next tick) — losing a
|
|
14
|
+
* reminder because the guard was closed would be silent data loss.
|
|
15
|
+
*
|
|
16
|
+
* ledger events (append-only, per assistantId): `triggered` + `delivered`, or `blocked`+reason.
|
|
17
|
+
*/
|
|
18
|
+
export function runTick(state, now = Date.now(), options = {}) {
|
|
19
|
+
const ts = Number.isFinite(now) ? now : Date.now();
|
|
20
|
+
const ledgerFile = typeof options.ledgerFile === 'string' && options.ledgerFile ? options.ledgerFile : null;
|
|
21
|
+
const only = typeof options.assistantId === 'string' && options.assistantId !== '' ? options.assistantId : null;
|
|
22
|
+
const record = (event) => {
|
|
23
|
+
if (ledgerFile) ledger.append(ledgerFile, event);
|
|
24
|
+
return event;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const delivered = [];
|
|
28
|
+
const blocked = [];
|
|
29
|
+
const fallbackId = defaultAssistantId(state.identity?.ownerKey ?? '');
|
|
30
|
+
const table = state.assistants && typeof state.assistants === 'object' ? state.assistants : {};
|
|
31
|
+
const tasks = Array.isArray(state.tasks) ? state.tasks : [];
|
|
32
|
+
|
|
33
|
+
const queued = [];
|
|
34
|
+
const unreachable = [];
|
|
35
|
+
/**
|
|
36
|
+
* Deliver one prompt and let the receipt decide the ledger event (contract §2.2):
|
|
37
|
+
* a bound pane that only *admitted* the text is `queued`, never `delivered` (I77).
|
|
38
|
+
*/
|
|
39
|
+
const deliverOne = (base) => {
|
|
40
|
+
const assistant = table[base.assistantId] ?? null;
|
|
41
|
+
const pane = assistant?.pane ?? null;
|
|
42
|
+
if (pane && typeof options.paneDeliver === 'function') {
|
|
43
|
+
const receipt = options.paneDeliver({ ...base, pane }) ?? { state: 'queued', reason: 'no-receipt' };
|
|
44
|
+
const common = { assistantId: base.assistantId, paneId: pane.paneId, agentName: pane.agentName ?? null, ...base };
|
|
45
|
+
if (receipt.state === 'accepted') {
|
|
46
|
+
return delivered.push(record({
|
|
47
|
+
event: 'delivered',
|
|
48
|
+
...common,
|
|
49
|
+
surface: 'herdr',
|
|
50
|
+
status: receipt.status ?? null,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
if (receipt.state === 'unreachable') {
|
|
54
|
+
// The pane is gone: drop the binding so the next `fleet up` can rebuild it. The
|
|
55
|
+
// assistant, its tasks and its history stay (I75).
|
|
56
|
+
assistant.pane = null;
|
|
57
|
+
return unreachable.push(record({ event: 'unreachable', ...common, reason: receipt.reason ?? 'unreachable' }));
|
|
58
|
+
}
|
|
59
|
+
return queued.push(record({ event: 'queued', ...common, reason: receipt.reason ?? 'queued' }));
|
|
60
|
+
}
|
|
61
|
+
// No pane: the in-process/outbox path (unchanged), but `paneId` is always present.
|
|
62
|
+
return delivered.push(record({ event: 'delivered', paneId: null, ...base }));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ① Due tasks (explicit user reminders): min-interval/cooldown do not apply, the user asked.
|
|
66
|
+
for (const task of tasks) {
|
|
67
|
+
if (!task || typeof task !== 'object' || task.active === false) continue;
|
|
68
|
+
if (!Number.isFinite(task.dueAt) || task.dueAt > ts) continue;
|
|
69
|
+
const assistantId = typeof task.assistantId === 'string' && task.assistantId ? task.assistantId : fallbackId;
|
|
70
|
+
if (only && assistantId !== only) continue;
|
|
71
|
+
const res = reserveTrigger(state, ts, assistantId, { ignoreEpoch: true, skipIntervalGates: true });
|
|
72
|
+
if (res.reserved) {
|
|
73
|
+
task.lastRunAt = ts;
|
|
74
|
+
if (task.kind === 'every') task.dueAt = ts + (Number.isFinite(task.everyMs) ? task.everyMs : 0);
|
|
75
|
+
else task.active = false;
|
|
76
|
+
const assistant = table[assistantId];
|
|
77
|
+
if (assistant) {
|
|
78
|
+
assistant.lastFiredAt = ts;
|
|
79
|
+
assistant.firedCount = (Number.isFinite(assistant.firedCount) ? assistant.firedCount : 0) + 1;
|
|
80
|
+
}
|
|
81
|
+
record({ event: 'triggered', assistantId, taskId: task.id, taskKind: task.kind, source: 'tick' });
|
|
82
|
+
deliverOne({
|
|
83
|
+
assistantId,
|
|
84
|
+
taskId: task.id,
|
|
85
|
+
taskKind: task.kind,
|
|
86
|
+
source: 'tick',
|
|
87
|
+
prompt: task.prompt ?? null,
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
blocked.push(record({ event: 'blocked', assistantId, taskId: task.id, source: 'tick', reason: res.reason }));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ② Idle sweep: only enabled assistants, only throttle reasons are recorded (no ledger spam).
|
|
95
|
+
for (const [assistantId, assistant] of Object.entries(table)) {
|
|
96
|
+
if (!assistant || assistant.enabled !== true) continue;
|
|
97
|
+
if (only && assistantId !== only) continue;
|
|
98
|
+
const result = evaluateIdle(state, ts, {}, assistantId);
|
|
99
|
+
if (result.idle) {
|
|
100
|
+
const res = reserveTrigger(state, ts, assistantId);
|
|
101
|
+
if (res.reserved) {
|
|
102
|
+
assistant.lastFiredAt = ts;
|
|
103
|
+
assistant.firedCount = (Number.isFinite(assistant.firedCount) ? assistant.firedCount : 0) + 1;
|
|
104
|
+
record({ event: 'triggered', assistantId, source: 'tick', reason: 'idle' });
|
|
105
|
+
deliverOne({ assistantId, source: 'tick', reason: 'idle' });
|
|
106
|
+
} else {
|
|
107
|
+
blocked.push(record({ event: 'blocked', assistantId, source: 'tick', reason: res.reason }));
|
|
108
|
+
}
|
|
109
|
+
} else if (THROTTLE_REASONS.includes(result.reason)) {
|
|
110
|
+
blocked.push(record({ event: 'blocked', assistantId, source: 'tick', reason: result.reason }));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
now: ts,
|
|
116
|
+
assistantId: only,
|
|
117
|
+
assistants: Object.keys(table).length,
|
|
118
|
+
delivered: delivered.length,
|
|
119
|
+
blocked: blocked.length,
|
|
120
|
+
queued: queued.length,
|
|
121
|
+
unreachable: unreachable.length,
|
|
122
|
+
deliveries: delivered,
|
|
123
|
+
queued_events: queued,
|
|
124
|
+
unreachable_events: unreachable,
|
|
125
|
+
blocks: blocked,
|
|
126
|
+
pending: tasks.filter((t) => t && t.active !== false).length,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The fleet view shared by every tool surface (MCP / Pi / DSH) and the CLI.
|
|
2
|
+
//
|
|
3
|
+
// It is a thin join: the *binding* comes from pactive state (I76), the *status* comes from herdr
|
|
4
|
+
// (`agent list`). When herdr cannot be asked, the status is `null` → rendered as "unobserved";
|
|
5
|
+
// it is never guessed (I78).
|
|
6
|
+
import * as herdr from './index.mjs';
|
|
7
|
+
|
|
8
|
+
/** herdr `agent list` → `{available, ok, reason, map: agentName → agent_status}`. */
|
|
9
|
+
export function herdrStatus(env = process.env) {
|
|
10
|
+
const probe = herdr.available({ env });
|
|
11
|
+
const map = new Map();
|
|
12
|
+
if (!probe.ok) return { available: false, ok: false, reason: probe.reason, map };
|
|
13
|
+
const listed = herdr.list({ env });
|
|
14
|
+
if (listed.ok) for (const agent of listed.agents) if (agent.agentName) map.set(agent.agentName, agent.status);
|
|
15
|
+
return { available: true, ok: listed.ok, reason: listed.ok ? null : listed.reason, map };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `{ok, count, herdr, rows}` — one row per assistant: identity ↔ pane ↔ status ↔ last delivery. */
|
|
19
|
+
export function fleetPayload(core, state, paths, env = process.env) {
|
|
20
|
+
const status = herdrStatus(env);
|
|
21
|
+
const rows = core.fleet.view(state, {
|
|
22
|
+
ledgerFile: paths.ledgerFile,
|
|
23
|
+
statusOf: (pane) => (pane.agentName ? status.map.get(pane.agentName) ?? null : null),
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
count: rows.length,
|
|
28
|
+
herdr: { available: status.available, reason: status.reason },
|
|
29
|
+
rows,
|
|
30
|
+
};
|
|
31
|
+
}
|