c8ctl-plugin-nano 1.55.1 → 1.56.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/README.md +53 -0
- package/agent-instance.mjs +451 -0
- package/c8ctl-plugin.js +555 -22
- package/package.json +9 -8
- package/supervisor-engine.mjs +12 -0
package/README.md
CHANGED
|
@@ -980,6 +980,59 @@ How it works and where things live:
|
|
|
980
980
|
- Stopping is SIGTERM → grace → SIGKILL, per worker and for the daemon; `stop`
|
|
981
981
|
always clears `supervisor.json` so a stale marker never wedges a future start.
|
|
982
982
|
|
|
983
|
+
### Surviving SSH logout: `supervisor install` / `uninstall`
|
|
984
|
+
|
|
985
|
+
On **macOS**, a supervisor started over SSH is bound to your SSH login session's
|
|
986
|
+
launchd/bootstrap context. When you **log out of that SSH session**, macOS tears
|
|
987
|
+
the per-session context down and the orphaned daemon + workers lose their
|
|
988
|
+
network / mDNS resolution path — the fleet does **not** exit cleanly, it
|
|
989
|
+
**wedges**: the activation loop spins on `SDK activateJobs failed: fetch failed`
|
|
990
|
+
forever and claims **zero** jobs (workers may still show `running` / agentic
|
|
991
|
+
`disconnected`). `setsid`/double-fork detachment is not enough there — the daemon
|
|
992
|
+
must live in a **persistent per-user launchd domain** (`gui/$UID`). **Linux is
|
|
993
|
+
unaffected**: under systemd-logind with the default `KillUserProcesses=no` a
|
|
994
|
+
`setsid`'d daemon keeps full network access after logout.
|
|
995
|
+
|
|
996
|
+
Give the supervisor a session-independent launch path so the fleet survives
|
|
997
|
+
logout and comes back at login/reboot:
|
|
998
|
+
|
|
999
|
+
```bash
|
|
1000
|
+
c8ctl nano supervisor install # install + start the service
|
|
1001
|
+
c8ctl nano supervisor uninstall # stop + remove it
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
- **macOS** — writes a per-user **LaunchAgent** and bootstraps it into `gui/$UID`
|
|
1005
|
+
(`launchctl bootstrap gui/$UID …`, `RunAtLoad`, crash-only `KeepAlive`). The
|
|
1006
|
+
plist lives at `~/Library/LaunchAgents/io.nanobpm.c8ctl-nano.supervisor.<hash>.plist`
|
|
1007
|
+
(the `<hash>` is derived from the state home, so distinct `C8CTL_NANO_HOME`
|
|
1008
|
+
instances get distinct services).
|
|
1009
|
+
- **Linux** — writes a `systemd --user` unit
|
|
1010
|
+
(`~/.config/systemd/user/c8ctl-nano-supervisor-<hash>.service`), enables + starts
|
|
1011
|
+
it, and turns on **lingering** (`loginctl enable-linger`) so it survives logout
|
|
1012
|
+
even where `KillUserProcesses=yes`. Where `systemd --user` is unavailable, no
|
|
1013
|
+
service is needed — the existing `setsid` daemon already survives logout — and
|
|
1014
|
+
`install` says so.
|
|
1015
|
+
- The service inherits only a **curated env** (`PATH`, `HOME`, `LANG`,
|
|
1016
|
+
`C8CTL_NANO_HOME`, the CLI entry) — never your whole SSH environment — so
|
|
1017
|
+
short-lived tokens are not persisted into a plist/unit that outlives the session.
|
|
1018
|
+
- `KeepAlive`/`Restart` are **crash-only**: a `supervisor stop` (clean exit) stays
|
|
1019
|
+
down; only a crash is restarted.
|
|
1020
|
+
|
|
1021
|
+
When the service **is installed**, every path that starts the daemon
|
|
1022
|
+
(`supervisor start`, and a bare `supervisor` / `supervisor attach`) brings it up
|
|
1023
|
+
**through the service**: it kickstarts the LaunchAgent when a clean `stop` left it
|
|
1024
|
+
down (a plain `kickstart`, never `-k`, so a running fleet is not bounced) and then
|
|
1025
|
+
**adopts** that service-owned daemon instead of spawning a second, session-bound
|
|
1026
|
+
one. Only if the service cannot be started does it fall back to a detached spawn
|
|
1027
|
+
(saying so).
|
|
1028
|
+
|
|
1029
|
+
When you run `supervisor start` or `attach` **over SSH on macOS without the
|
|
1030
|
+
installed service**, the CLI **auto-reparents** the daemon into the `gui/$UID`
|
|
1031
|
+
launchd domain (equivalent to `install`) so it survives logout, and tells you. If
|
|
1032
|
+
it cannot (e.g. `launchctl` is unavailable, or you set `C8CTL_NANO_NO_LAUNCHD=1`),
|
|
1033
|
+
it prints a prominent **warning** pointing at `supervisor install` instead of
|
|
1034
|
+
silently leaving a fleet that will wedge on logout.
|
|
1035
|
+
|
|
983
1036
|
## Composing a workforce: `workforce`
|
|
984
1037
|
|
|
985
1038
|
`supervisor` is imperative — you compose a fleet with a `start --worker …` plus a
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// Engine-native AgentInstance / AgentHistory producer (issue #194).
|
|
2
|
+
//
|
|
3
|
+
// When the harness activates a job whose element carries
|
|
4
|
+
// `zeebe:agentDefinition agentType="external"`, this module mints an engine-native
|
|
5
|
+
// `AgentInstance` (Camunda 8.10 / nanobpmn) and streams the agent's ACP turns into
|
|
6
|
+
// the append-only `AgentHistory` via the host-provided `@camunda8/orchestration-cluster-api`
|
|
7
|
+
// SDK client (`createAgentInstance` / `updateAgentInstance`). This is the DURABLE
|
|
8
|
+
// historical/metrics producer that replaces the fragile app-side transcript relay
|
|
9
|
+
// side-channel — the relay stays as a live overlay; nothing here removes it.
|
|
10
|
+
//
|
|
11
|
+
// The ACP → AgentHistory translation is a port of the canonical
|
|
12
|
+
// `@nanobpm/agentic/session/acp` `classifyUpdate` bridge (the same classifier the
|
|
13
|
+
// transcript-chunk producer uses), so a producer and the engine's read model can
|
|
14
|
+
// never diverge on the semantic shape of a turn.
|
|
15
|
+
//
|
|
16
|
+
// Everything at the process edge is injected (the SDK client, the classifier, the
|
|
17
|
+
// clock), so the producer is driven deterministically under `node --test` with an
|
|
18
|
+
// in-memory fake client. The producer is ENTIRELY best-effort: a failure to mint or
|
|
19
|
+
// append an AgentInstance must NEVER crash the harness or change `job.complete`
|
|
20
|
+
// behaviour — the AgentInstance lifecycle is orthogonal to job completion.
|
|
21
|
+
|
|
22
|
+
import { sessionAcp as defaultSessionAcp } from './agentic.mjs';
|
|
23
|
+
|
|
24
|
+
// The two AgentInstance surfaces we call on the host SDK client. A client missing
|
|
25
|
+
// either method (an older SDK) disables the producer rather than throwing.
|
|
26
|
+
const SDK_CREATE = 'createAgentInstance';
|
|
27
|
+
const SDK_UPDATE = 'updateAgentInstance';
|
|
28
|
+
|
|
29
|
+
const isNonBlank = (v) => v != null && String(v).trim() !== '';
|
|
30
|
+
const isPlainObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Is this activated job an `external` (job-backed) agent job — i.e. one whose
|
|
34
|
+
* element carries `zeebe:agentDefinition agentType="external"`?
|
|
35
|
+
*
|
|
36
|
+
* The engine surfaces exactly this eligibility as the pair of fields it stamps on
|
|
37
|
+
* an external agent job's activation: an opaque per-activation `jobLease` token
|
|
38
|
+
* (distinct from the job's `deadline`; nanobpmn #1106) plus the `elementInstanceKey`
|
|
39
|
+
* the AgentInstance correlates on. The engine-native `aiAgentTask`/`aiAgentSubProcess`
|
|
40
|
+
* variants auto-mint their AgentInstance and never create an activatable job, so any
|
|
41
|
+
* job a worker actually activates that carries a lease token IS an external agent
|
|
42
|
+
* job. Absence of either field means "not an external agent job" → the producer
|
|
43
|
+
* stays fully inert (no behaviour change for ordinary service jobs).
|
|
44
|
+
*/
|
|
45
|
+
export function isExternalAgentJob(job) {
|
|
46
|
+
if (!isPlainObject(job)) return false;
|
|
47
|
+
return isNonBlank(job.jobLease) && isNonBlank(job.elementInstanceKey);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Best-effort provider inference from a model identifier (openai/anthropic/…). */
|
|
51
|
+
export function inferProvider(model) {
|
|
52
|
+
const m = String(model || '').toLowerCase();
|
|
53
|
+
if (!m) return 'unknown';
|
|
54
|
+
if (/(gpt|o\d|davinci|openai)/.test(m)) return 'openai';
|
|
55
|
+
if (/(claude|opus|sonnet|haiku|anthropic)/.test(m)) return 'anthropic';
|
|
56
|
+
if (/(gemini|palm|bison|google)/.test(m)) return 'google';
|
|
57
|
+
if (/(llama|meta)/.test(m)) return 'meta';
|
|
58
|
+
if (/(mistral|mixtral)/.test(m)) return 'mistral';
|
|
59
|
+
if (/(deepseek)/.test(m)) return 'deepseek';
|
|
60
|
+
if (/(qwen)/.test(m)) return 'qwen';
|
|
61
|
+
if (/(kimi|moonshot)/.test(m)) return 'moonshot';
|
|
62
|
+
if (/(grok|xai)/.test(m)) return 'xai';
|
|
63
|
+
return 'unknown';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Seed the concrete agent `definition` (model, provider, systemPrompt) from the
|
|
68
|
+
* actual worker/model that claimed the job. The static `agentDefinition` marker is
|
|
69
|
+
* only the eligibility flag; the concrete definition is runtime — the model comes
|
|
70
|
+
* from the worker profile, the systemPrompt from the resolved base prompt.
|
|
71
|
+
*/
|
|
72
|
+
export function deriveAgentDefinition({ profile, envelope } = {}) {
|
|
73
|
+
const model = isNonBlank(profile?.model) ? String(profile.model) : 'unknown';
|
|
74
|
+
const provider = isNonBlank(profile?.provider)
|
|
75
|
+
? String(profile.provider)
|
|
76
|
+
: inferProvider(model);
|
|
77
|
+
const systemPrompt = isNonBlank(envelope?.task?.prompt) ? String(envelope.task.prompt) : '';
|
|
78
|
+
return { model, provider, systemPrompt };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Map the ACP classifier's message role to the AgentHistory role enum. ACP has no
|
|
82
|
+
// distinct REASONING role, so a `reasoning` chunk folds into ASSISTANT.
|
|
83
|
+
function historyRole(acpRole) {
|
|
84
|
+
return acpRole === 'user' ? 'USER' : 'ASSISTANT';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A short, stable hash for deriving a content-addressed historyItemId when the ACP
|
|
88
|
+
// agent omits a messageId (so identical retried content dedups; new content appends).
|
|
89
|
+
function shortHash(text) {
|
|
90
|
+
let h = 5381;
|
|
91
|
+
const s = String(text);
|
|
92
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
93
|
+
return (h >>> 0).toString(36);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Extract per-call metrics from an ACP update when the agent carries them (many
|
|
97
|
+
// ACP agents do not — a documented ACP fidelity gap — so this is usually absent).
|
|
98
|
+
// Reads the common usage locations and maps to the AgentHistory item metric field
|
|
99
|
+
// names. Returns undefined when nothing usable is present.
|
|
100
|
+
function extractMetrics(update) {
|
|
101
|
+
const src =
|
|
102
|
+
(isPlainObject(update?.usage) && update.usage) ||
|
|
103
|
+
(isPlainObject(update?.tokenUsage) && update.tokenUsage) ||
|
|
104
|
+
(isPlainObject(update?._meta?.usage) && update._meta.usage) ||
|
|
105
|
+
(isPlainObject(update?.metrics) && update.metrics) ||
|
|
106
|
+
null;
|
|
107
|
+
if (!src) return undefined;
|
|
108
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : undefined);
|
|
109
|
+
const out = {};
|
|
110
|
+
const inputTokens = num(src.inputTokens ?? src.input_tokens ?? src.promptTokens ?? src.prompt_tokens);
|
|
111
|
+
const outputTokens = num(src.outputTokens ?? src.output_tokens ?? src.completionTokens ?? src.completion_tokens);
|
|
112
|
+
const reasoningTokenCount = num(src.reasoningTokenCount ?? src.reasoning_tokens ?? src.reasoningTokens);
|
|
113
|
+
const cacheCreationTokenCount = num(src.cacheCreationTokenCount ?? src.cache_creation_input_tokens ?? src.cacheCreationInputTokens);
|
|
114
|
+
const cacheReadTokenCount = num(src.cacheReadTokenCount ?? src.cache_read_input_tokens ?? src.cacheReadInputTokens);
|
|
115
|
+
const durationMs = num(src.durationMs ?? src.duration_ms ?? src.latencyMs ?? src.latency_ms);
|
|
116
|
+
if (inputTokens !== undefined) out.inputTokens = inputTokens;
|
|
117
|
+
if (outputTokens !== undefined) out.outputTokens = outputTokens;
|
|
118
|
+
if (reasoningTokenCount !== undefined) out.reasoningTokenCount = reasoningTokenCount;
|
|
119
|
+
if (cacheCreationTokenCount !== undefined) out.cacheCreationTokenCount = cacheCreationTokenCount;
|
|
120
|
+
if (cacheReadTokenCount !== undefined) out.cacheReadTokenCount = cacheReadTokenCount;
|
|
121
|
+
if (durationMs !== undefined) out.durationMs = durationMs;
|
|
122
|
+
return Object.keys(out).length ? out : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A tool-result's `result` becomes an OBJECT content block when it is structured
|
|
126
|
+
// JSON, or a TEXT block when it is a plain string. Null/undefined → empty content.
|
|
127
|
+
function contentForResult(result) {
|
|
128
|
+
if (result == null) return [];
|
|
129
|
+
if (typeof result === 'string') return [{ contentType: 'TEXT', text: result }];
|
|
130
|
+
return [{ contentType: 'OBJECT', object: result }];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create an AgentInstance producer bound to one activated external agent job.
|
|
135
|
+
*
|
|
136
|
+
* Returns a small facade the harness drives:
|
|
137
|
+
* - `activate()` — mint the AgentInstance (lease-gated on the job) with the
|
|
138
|
+
* opening CONFIGURATION turn. Idempotent: correlates on the elementInstanceKey,
|
|
139
|
+
* so a reactivation (ask-a-question loop) folds into the SAME instance and never
|
|
140
|
+
* creates a second one.
|
|
141
|
+
* - `ingest(rawUpdate)` — feed one raw ACP `session/update` (non-blocking; the SDK
|
|
142
|
+
* append is enqueued so the ACP hot path never blocks).
|
|
143
|
+
* - `complete(ok)` — drain pending appends, then (on a successful job end) update
|
|
144
|
+
* the instance status to COMPLETED. `job.complete` is unchanged and orthogonal.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} opts
|
|
147
|
+
* @param {object} opts.camunda Host SDK client (createAgentInstance/updateAgentInstance).
|
|
148
|
+
* @param {object} opts.job The activated job (jobKey/jobLease/elementInstanceKey/elementId).
|
|
149
|
+
* @param {object} [opts.profile] The worker profile (model/provider seed the definition).
|
|
150
|
+
* @param {object} [opts.envelope] The normalized task envelope (task.prompt → systemPrompt).
|
|
151
|
+
* @param {object} [opts.logger] Output-mode-aware logger (warn/info/debug).
|
|
152
|
+
* @param {() => number} [opts.now] Injected clock (ms epoch); defaults to Date.now.
|
|
153
|
+
* @param {object} [opts.sessionAcp] The ACP classifier surface (defaults to the package bridge).
|
|
154
|
+
*/
|
|
155
|
+
export function createAgentInstanceProducer(opts = {}) {
|
|
156
|
+
const {
|
|
157
|
+
camunda,
|
|
158
|
+
job,
|
|
159
|
+
profile = {},
|
|
160
|
+
envelope = {},
|
|
161
|
+
logger = console,
|
|
162
|
+
now = () => Date.now(),
|
|
163
|
+
sessionAcp = defaultSessionAcp,
|
|
164
|
+
} = opts;
|
|
165
|
+
|
|
166
|
+
const classify = typeof sessionAcp?.classifyUpdate === 'function' ? sessionAcp.classifyUpdate : null;
|
|
167
|
+
|
|
168
|
+
const jobKey = job?.jobKey != null ? String(job.jobKey) : '';
|
|
169
|
+
const jobLease = job?.jobLease != null ? String(job.jobLease) : '';
|
|
170
|
+
const elementInstanceKey = job?.elementInstanceKey != null ? String(job.elementInstanceKey) : '';
|
|
171
|
+
const elementId = job?.elementId != null ? String(job.elementId) : null;
|
|
172
|
+
|
|
173
|
+
// The producer is a no-op unless every precondition holds: a usable SDK client,
|
|
174
|
+
// an external agent job, and the ACP classifier. Any missing piece leaves the
|
|
175
|
+
// harness path byte-for-byte unchanged.
|
|
176
|
+
const usable =
|
|
177
|
+
!!camunda &&
|
|
178
|
+
typeof camunda[SDK_CREATE] === 'function' &&
|
|
179
|
+
typeof camunda[SDK_UPDATE] === 'function' &&
|
|
180
|
+
!!classify &&
|
|
181
|
+
isExternalAgentJob(job);
|
|
182
|
+
|
|
183
|
+
let disabled = !usable;
|
|
184
|
+
let agentInstanceKey = null;
|
|
185
|
+
let activated = false;
|
|
186
|
+
let loopIteration = 0;
|
|
187
|
+
let queue = Promise.resolve();
|
|
188
|
+
// Coalesce streamed message chunks (same messageId + role) into one turn, flushed
|
|
189
|
+
// on a role/message boundary, a tool event, or completion — the engine dedups on
|
|
190
|
+
// historyItemId (it does NOT merge), so a turn must be appended exactly once, whole.
|
|
191
|
+
let pendingMessage = null;
|
|
192
|
+
// callId → toolName, so a TOOL_RESULT turn can reference the originating call name.
|
|
193
|
+
const toolNames = new Map();
|
|
194
|
+
|
|
195
|
+
const iso = () => new Date(now()).toISOString();
|
|
196
|
+
|
|
197
|
+
// Serialize an SDK call onto the queue so appends preserve order and `complete`
|
|
198
|
+
// can drain them. A rejection is swallowed (best-effort) but never breaks the chain.
|
|
199
|
+
const enqueue = (fn) => {
|
|
200
|
+
queue = queue.then(fn).catch((err) => {
|
|
201
|
+
logger?.debug?.(`AgentInstance producer: SDK call failed — ${err?.message || err}`);
|
|
202
|
+
});
|
|
203
|
+
return queue;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Append one AgentHistory turn via updateAgentInstance (one turn per call keeps the
|
|
207
|
+
// dedup boundary crisp). Only ever runs once the instance is minted.
|
|
208
|
+
const appendTurn = (turn, status) => {
|
|
209
|
+
if (disabled || !agentInstanceKey) return;
|
|
210
|
+
enqueue(async () => {
|
|
211
|
+
const req = {
|
|
212
|
+
agentInstanceKey,
|
|
213
|
+
elementInstanceKey,
|
|
214
|
+
jobKey,
|
|
215
|
+
jobLease,
|
|
216
|
+
history: [turn],
|
|
217
|
+
};
|
|
218
|
+
if (status) req.status = status;
|
|
219
|
+
await camunda[SDK_UPDATE](req);
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const flushMessage = () => {
|
|
224
|
+
if (!pendingMessage) return;
|
|
225
|
+
const text = pendingMessage.texts.join('');
|
|
226
|
+
const hasText = text.trim() !== '';
|
|
227
|
+
const hasMetrics = pendingMessage.metrics !== undefined;
|
|
228
|
+
const msg = pendingMessage;
|
|
229
|
+
pendingMessage = null;
|
|
230
|
+
if (!hasText && !hasMetrics) return;
|
|
231
|
+
const idBasis = isNonBlank(msg.messageId) ? String(msg.messageId) : `h:${shortHash(text)}`;
|
|
232
|
+
const turn = {
|
|
233
|
+
historyItemId: `${msg.role.toLowerCase()}:${idBasis}`,
|
|
234
|
+
loopIteration: msg.loopIteration,
|
|
235
|
+
role: msg.role,
|
|
236
|
+
content: hasText ? [{ contentType: 'TEXT', text }] : [],
|
|
237
|
+
producedAt: msg.producedAt,
|
|
238
|
+
};
|
|
239
|
+
if (msg.role === 'ASSISTANT' && hasMetrics) turn.metrics = msg.metrics;
|
|
240
|
+
appendTurn(turn, msg.role === 'ASSISTANT' ? 'THINKING' : undefined);
|
|
241
|
+
// A completed ASSISTANT response ends one loop iteration.
|
|
242
|
+
if (msg.role === 'ASSISTANT') loopIteration += 1;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const onToolCall = (c) => {
|
|
246
|
+
flushMessage();
|
|
247
|
+
if (isNonBlank(c.name)) toolNames.set(String(c.callId), String(c.name));
|
|
248
|
+
const turn = {
|
|
249
|
+
historyItemId: `toolcall:${c.callId}`,
|
|
250
|
+
loopIteration,
|
|
251
|
+
role: 'ASSISTANT',
|
|
252
|
+
content: [],
|
|
253
|
+
toolCalls: [
|
|
254
|
+
{
|
|
255
|
+
toolCallId: String(c.callId),
|
|
256
|
+
toolName: isNonBlank(c.name) ? String(c.name) : '',
|
|
257
|
+
elementId,
|
|
258
|
+
arguments: isPlainObject(c.args) ? c.args : null,
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
producedAt: iso(),
|
|
262
|
+
};
|
|
263
|
+
appendTurn(turn, 'TOOL_CALLING');
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const onToolResult = (c) => {
|
|
267
|
+
flushMessage();
|
|
268
|
+
const turn = {
|
|
269
|
+
historyItemId: `toolresult:${c.callId}`,
|
|
270
|
+
loopIteration,
|
|
271
|
+
role: 'TOOL_RESULT',
|
|
272
|
+
content: contentForResult(c.result),
|
|
273
|
+
toolCalls: [
|
|
274
|
+
{
|
|
275
|
+
toolCallId: String(c.callId),
|
|
276
|
+
toolName: toolNames.get(String(c.callId)) || '',
|
|
277
|
+
elementId,
|
|
278
|
+
arguments: null,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
producedAt: iso(),
|
|
282
|
+
};
|
|
283
|
+
appendTurn(turn);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
/** True once the AgentInstance has been minted (or is being minted). */
|
|
288
|
+
get active() {
|
|
289
|
+
return activated && !disabled;
|
|
290
|
+
},
|
|
291
|
+
get agentInstanceKey() {
|
|
292
|
+
return agentInstanceKey;
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Mint the AgentInstance (lease-gated) with an opening CONFIGURATION turn that
|
|
297
|
+
* establishes model/provider/systemPrompt/limits. Idempotent per element
|
|
298
|
+
* instance — safe to call once per activation; a reactivation reconciles onto
|
|
299
|
+
* the same instance rather than creating a second one. Best-effort: a rejected
|
|
300
|
+
* create (e.g. a stale lease) disables the producer and returns false.
|
|
301
|
+
*/
|
|
302
|
+
async activate() {
|
|
303
|
+
if (disabled || activated) return this.active;
|
|
304
|
+
activated = true;
|
|
305
|
+
const def = deriveAgentDefinition({ profile, envelope });
|
|
306
|
+
const configTurn = {
|
|
307
|
+
historyItemId: `configuration:${elementInstanceKey}`,
|
|
308
|
+
loopIteration: 0,
|
|
309
|
+
role: 'CONFIGURATION',
|
|
310
|
+
content: [],
|
|
311
|
+
producedAt: iso(),
|
|
312
|
+
model: def.model,
|
|
313
|
+
provider: def.provider,
|
|
314
|
+
};
|
|
315
|
+
if (isNonBlank(def.systemPrompt)) {
|
|
316
|
+
configTurn.systemPrompt = [{ contentType: 'TEXT', text: def.systemPrompt }];
|
|
317
|
+
}
|
|
318
|
+
const limits = deriveLimits(envelope);
|
|
319
|
+
if (limits) configTurn.limits = limits;
|
|
320
|
+
try {
|
|
321
|
+
const res = await camunda[SDK_CREATE]({
|
|
322
|
+
elementInstanceKey,
|
|
323
|
+
jobKey,
|
|
324
|
+
jobLease,
|
|
325
|
+
history: [configTurn],
|
|
326
|
+
});
|
|
327
|
+
agentInstanceKey =
|
|
328
|
+
(res && (res.agentInstanceKey ?? res.key)) != null
|
|
329
|
+
? String(res.agentInstanceKey ?? res.key)
|
|
330
|
+
: null;
|
|
331
|
+
if (!agentInstanceKey) {
|
|
332
|
+
// A reactivation reconciles the auto-minted/existing record; if the create
|
|
333
|
+
// result carries no key, fall back to the elementInstanceKey correlation is
|
|
334
|
+
// not possible for updates (they need the agentInstanceKey), so disable.
|
|
335
|
+
disabled = true;
|
|
336
|
+
logger?.warn?.('AgentInstance producer: create returned no agentInstanceKey; disabling durable transcript for this job.');
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
loopIteration = 1;
|
|
340
|
+
logger?.info?.(`AgentInstance ${agentInstanceKey} minted for element instance ${elementInstanceKey} (job ${jobKey}).`);
|
|
341
|
+
return true;
|
|
342
|
+
} catch (err) {
|
|
343
|
+
disabled = true;
|
|
344
|
+
logger?.warn?.(`AgentInstance producer: createAgentInstance failed — ${err?.message || err}; continuing without a durable transcript (job completion unaffected).`);
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Feed one raw ACP `session/update` (the `params.update`). Non-blocking: the
|
|
351
|
+
* translated turn's SDK append is enqueued. Malformed/ignored updates are
|
|
352
|
+
* dropped. Never throws.
|
|
353
|
+
*/
|
|
354
|
+
ingest(rawUpdate) {
|
|
355
|
+
if (disabled || !agentInstanceKey) return;
|
|
356
|
+
let classified;
|
|
357
|
+
try {
|
|
358
|
+
classified = classify(rawUpdate);
|
|
359
|
+
} catch {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (!classified || typeof classified !== 'object') return;
|
|
363
|
+
try {
|
|
364
|
+
switch (classified.kind) {
|
|
365
|
+
case 'message': {
|
|
366
|
+
const role = historyRole(classified.role);
|
|
367
|
+
if (
|
|
368
|
+
pendingMessage &&
|
|
369
|
+
(pendingMessage.messageId !== classified.messageId || pendingMessage.role !== role)
|
|
370
|
+
) {
|
|
371
|
+
flushMessage();
|
|
372
|
+
}
|
|
373
|
+
if (!pendingMessage) {
|
|
374
|
+
pendingMessage = {
|
|
375
|
+
role,
|
|
376
|
+
messageId: classified.messageId ?? null,
|
|
377
|
+
texts: [],
|
|
378
|
+
metrics: undefined,
|
|
379
|
+
loopIteration,
|
|
380
|
+
producedAt: iso(),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (isNonBlank(classified.text)) pendingMessage.texts.push(String(classified.text));
|
|
384
|
+
const m = extractMetrics(rawUpdate);
|
|
385
|
+
if (m) pendingMessage.metrics = { ...(pendingMessage.metrics || {}), ...m };
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
case 'tool-call':
|
|
389
|
+
onToolCall(classified);
|
|
390
|
+
break;
|
|
391
|
+
case 'tool-result':
|
|
392
|
+
onToolResult(classified);
|
|
393
|
+
break;
|
|
394
|
+
default:
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
} catch (err) {
|
|
398
|
+
logger?.debug?.(`AgentInstance producer: ingest failed — ${err?.message || err}`);
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
/** Drain any queued appends without transitioning status. */
|
|
403
|
+
async drain() {
|
|
404
|
+
flushMessage();
|
|
405
|
+
await queue;
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* End the AgentInstance lifecycle. Flushes any pending message turn, drains the
|
|
410
|
+
* append queue, then — only on a SUCCESSFUL job end (`ok`) — updates the instance
|
|
411
|
+
* status to COMPLETED (there is no separate completeAgentInstance verb). On a
|
|
412
|
+
* failed run the instance is left non-terminal so a retry/reactivation continues
|
|
413
|
+
* the same instance. Best-effort; never throws; `job.complete` is unaffected.
|
|
414
|
+
*/
|
|
415
|
+
async complete(ok = true) {
|
|
416
|
+
if (disabled || !agentInstanceKey) {
|
|
417
|
+
// Still drain any queued appends so a caller awaiting completion settles.
|
|
418
|
+
try { await this.drain(); } catch { /* best effort */ }
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
flushMessage();
|
|
422
|
+
if (ok) {
|
|
423
|
+
enqueue(async () => {
|
|
424
|
+
await camunda[SDK_UPDATE]({
|
|
425
|
+
agentInstanceKey,
|
|
426
|
+
elementInstanceKey,
|
|
427
|
+
jobKey,
|
|
428
|
+
jobLease,
|
|
429
|
+
status: 'COMPLETED',
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
try { await queue; } catch { /* best effort */ }
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Derive the agent execution `limits` from the task envelope, when present. Maps the
|
|
440
|
+
* envelope's `task.maxIterations` onto `maxModelCalls`; token/tool ceilings are not
|
|
441
|
+
* expressed in the envelope today, so they are left as -1 (no limit). Returns
|
|
442
|
+
* undefined when nothing constrains the run (the create then defaults all to -1).
|
|
443
|
+
*/
|
|
444
|
+
export function deriveLimits(envelope) {
|
|
445
|
+
const maxIterations = envelope?.task?.maxIterations;
|
|
446
|
+
const n = Number(maxIterations);
|
|
447
|
+
if (Number.isFinite(n) && n > 0) {
|
|
448
|
+
return { maxModelCalls: Math.trunc(n), maxToolCalls: -1, maxTokens: -1 };
|
|
449
|
+
}
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
package/c8ctl-plugin.js
CHANGED
|
@@ -72,6 +72,10 @@ import { createLogRing, resolveLogMaxBytes } from './supervisor-log-ring.mjs';
|
|
|
72
72
|
// raw ACP `session/update` to the exact transcript-chunk bytes the cockpit decodes,
|
|
73
73
|
// replacing the plugin's former hand-rolled `nwfTranscriptEvent` envelope grammar.
|
|
74
74
|
import { sessionAcp as agenticSessionAcp } from './agentic.mjs';
|
|
75
|
+
// Engine-native AgentInstance / AgentHistory durable-transcript producer (issue
|
|
76
|
+
// #194): mints an AgentInstance for an `external` agent job and appends each ACP
|
|
77
|
+
// turn to the engine's append-only AgentHistory via the host SDK client.
|
|
78
|
+
import { createAgentInstanceProducer, isExternalAgentJob } from './agent-instance.mjs';
|
|
75
79
|
|
|
76
80
|
const requireFromHere = createRequire(import.meta.url);
|
|
77
81
|
const pluginDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -4879,7 +4883,7 @@ const ACP_MAX_LINE_BYTES = 8 * 1024 * 1024; // 8 MiB
|
|
|
4879
4883
|
// and every caller work unchanged. Because the raw stream is JSON-RPC (not human
|
|
4880
4884
|
// output), `stdout` here is the accumulated human-readable transcript text (what
|
|
4881
4885
|
// we relay), and `stderr` is the child's real stderr (agent diagnostics).
|
|
4882
|
-
function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, relayTap = null, stream = false, streamPrefix = '', onStreamOut, onStreamErr, permission = 'yolo', shell = false }) {
|
|
4886
|
+
function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, relayTap = null, stream = false, streamPrefix = '', onStreamOut, onStreamErr, permission = 'yolo', shell = false, onAcpUpdate = null }) {
|
|
4883
4887
|
return new Promise((resolve) => {
|
|
4884
4888
|
const logger = getLogger();
|
|
4885
4889
|
const humanChunks = [];
|
|
@@ -5222,7 +5226,17 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
5222
5226
|
}
|
|
5223
5227
|
// A request or notification FROM the agent.
|
|
5224
5228
|
if (typeof msg.method === 'string') {
|
|
5225
|
-
if (msg.method === 'session/update') {
|
|
5229
|
+
if (msg.method === 'session/update') {
|
|
5230
|
+
emitTranscript(msg.params?.update);
|
|
5231
|
+
// #194: durable engine-native producer seam. In parallel with the
|
|
5232
|
+
// transcript-chunk relay overlay, hand the SAME raw `session/update` to
|
|
5233
|
+
// the AgentInstance producer so each ACP turn is appended to the engine's
|
|
5234
|
+
// append-only AgentHistory. Best-effort and non-blocking (the producer
|
|
5235
|
+
// enqueues its own SDK append), so a producer failure never disturbs the
|
|
5236
|
+
// ACP loop or the transcript relay. Inert when no producer is wired.
|
|
5237
|
+
if (onAcpUpdate) { try { onAcpUpdate(msg.params?.update); } catch { /* producer best-effort */ } }
|
|
5238
|
+
return;
|
|
5239
|
+
}
|
|
5226
5240
|
if (msg.method === 'session/request_permission') {
|
|
5227
5241
|
if (msg.id !== undefined) handlePermission(msg.id, msg.params);
|
|
5228
5242
|
return;
|
|
@@ -5540,7 +5554,7 @@ function baseAgentEnv(profile, job) {
|
|
|
5540
5554
|
* Both paths resolve to the same result contract.
|
|
5541
5555
|
*/
|
|
5542
5556
|
function runAgentJob(profile, job, opts = {}) {
|
|
5543
|
-
const { timeoutMs, idleTimeoutMs, recoveryWindowMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs, terminal = 'pipe', protocol = 'pipe', permission = 'yolo', relaySession = null, ptyFactory, nudgePayload = null } = opts;
|
|
5557
|
+
const { timeoutMs, idleTimeoutMs, recoveryWindowMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs, terminal = 'pipe', protocol = 'pipe', permission = 'yolo', relaySession = null, ptyFactory, nudgePayload = null, onAcpUpdate = null } = opts;
|
|
5544
5558
|
// #110: `protocol`/`permission` drive the ACP executor branch below. The
|
|
5545
5559
|
// pipe/PTY paths are unchanged, so `protocol === 'pipe'` behaviour is identical.
|
|
5546
5560
|
// A `nudgePayload` (#678) carries the bespoke "re-emit your result" prompt for a
|
|
@@ -5628,6 +5642,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
5628
5642
|
onStreamOut,
|
|
5629
5643
|
onStreamErr,
|
|
5630
5644
|
permission,
|
|
5645
|
+
onAcpUpdate,
|
|
5631
5646
|
});
|
|
5632
5647
|
}
|
|
5633
5648
|
|
|
@@ -7501,6 +7516,25 @@ async function workAgent(req, flags) {
|
|
|
7501
7516
|
const runId = randomUUID();
|
|
7502
7517
|
if (isContainer) liveRunIds.add(runId);
|
|
7503
7518
|
|
|
7519
|
+
// #194: durable engine-native AgentInstance producer. For an `external`
|
|
7520
|
+
// agent job (one carrying the activation's lease token + elementInstanceKey)
|
|
7521
|
+
// mint an AgentInstance now — lease-gated on THIS activation — seeding the
|
|
7522
|
+
// concrete definition (model/provider/systemPrompt) from the worker/model
|
|
7523
|
+
// and task prompt. Correlated on the elementInstanceKey, so a reactivation
|
|
7524
|
+
// (ask-a-question loop) folds into the SAME instance rather than a second.
|
|
7525
|
+
// Entirely best-effort and orthogonal to job completion: a mint failure (or
|
|
7526
|
+
// the kill-switch NANO_AGENT_INSTANCE=off) leaves the harness path unchanged.
|
|
7527
|
+
// Each ACP `session/update` is fed to `producer.ingest` (wired via runOpts'
|
|
7528
|
+
// `onAcpUpdate`), which appends the translated turn to AgentHistory; on job
|
|
7529
|
+
// end the instance is driven to COMPLETED (success only) — `job.complete`
|
|
7530
|
+
// fires exactly as before regardless.
|
|
7531
|
+
let agentInstanceProducer = null;
|
|
7532
|
+
const agentInstanceOff = String(process.env.NANO_AGENT_INSTANCE || '').trim().toLowerCase() === 'off';
|
|
7533
|
+
if (!agentInstanceOff && isExternalAgentJob(job)) {
|
|
7534
|
+
agentInstanceProducer = createAgentInstanceProducer({ camunda, job, profile, envelope, logger });
|
|
7535
|
+
try { await agentInstanceProducer.activate(); } catch { /* best effort */ }
|
|
7536
|
+
}
|
|
7537
|
+
|
|
7504
7538
|
// Fail-closed on a half-specified repository envelope (issue #129,
|
|
7505
7539
|
// hardening 2): a `repository` block that declares intent (any field set)
|
|
7506
7540
|
// but whose `url` is absent or not a usable clone target almost always
|
|
@@ -7661,6 +7695,11 @@ async function workAgent(req, flags) {
|
|
|
7661
7695
|
// spying never corrupts a structured/JSON output mode.
|
|
7662
7696
|
onStreamOut: stream ? (line) => logger.info(line) : undefined,
|
|
7663
7697
|
onStreamErr: stream ? (line) => logger.warn(line) : undefined,
|
|
7698
|
+
// #194: feed each raw ACP `session/update` to the durable AgentInstance
|
|
7699
|
+
// producer so its turn is appended to the engine's AgentHistory. Inert
|
|
7700
|
+
// when no producer was minted (non-external job / off / mint failed) and
|
|
7701
|
+
// for non-ACP harnesses (no session/updates are emitted). Best-effort.
|
|
7702
|
+
onAcpUpdate: agentInstanceProducer ? (u) => agentInstanceProducer.ingest(u) : undefined,
|
|
7664
7703
|
};
|
|
7665
7704
|
result = await runAgentJob(profile, job, runOpts);
|
|
7666
7705
|
|
|
@@ -7698,6 +7737,14 @@ async function workAgent(req, flags) {
|
|
|
7698
7737
|
}
|
|
7699
7738
|
}
|
|
7700
7739
|
|
|
7740
|
+
// #194: end the AgentInstance lifecycle. Flush any pending turn and drain
|
|
7741
|
+
// the append queue, then drive the instance to COMPLETED on a successful
|
|
7742
|
+
// job end (a failed run leaves it non-terminal so a retry/reactivation
|
|
7743
|
+
// continues the same instance). Best-effort — never disturbs job settlement.
|
|
7744
|
+
if (agentInstanceProducer) {
|
|
7745
|
+
try { await agentInstanceProducer.complete(result.ok); } catch { /* best effort */ }
|
|
7746
|
+
}
|
|
7747
|
+
|
|
7701
7748
|
// Finalize git only when the harness succeeded — never push a
|
|
7702
7749
|
// half-finished workspace.
|
|
7703
7750
|
if (provisioned && result.ok) {
|
|
@@ -9277,7 +9324,7 @@ function supervisorRequest(req, { socketPath, timeoutMs, responseTimeoutMs = SUP
|
|
|
9277
9324
|
* Ensure a daemon is running, spawning it detached if not, and return its
|
|
9278
9325
|
* running state. Polls the control socket until it answers a status request.
|
|
9279
9326
|
*/
|
|
9280
|
-
async function startSupervisorDaemon() {
|
|
9327
|
+
async function startSupervisorDaemon({ adoptOnly = false } = {}) {
|
|
9281
9328
|
const existing = runningSupervisor();
|
|
9282
9329
|
if (existing) return existing;
|
|
9283
9330
|
|
|
@@ -9285,18 +9332,32 @@ async function startSupervisorDaemon() {
|
|
|
9285
9332
|
// The state file may be missing (deleted, cleaned up, or not yet written)
|
|
9286
9333
|
// while a daemon is still listening on the deterministic socket. Adopt that
|
|
9287
9334
|
// live daemon instead of spawning a second one that would orphan the
|
|
9288
|
-
// original and its workers.
|
|
9289
|
-
|
|
9290
|
-
|
|
9291
|
-
|
|
9292
|
-
|
|
9293
|
-
|
|
9294
|
-
|
|
9295
|
-
const
|
|
9296
|
-
|
|
9297
|
-
|
|
9298
|
-
|
|
9299
|
-
|
|
9335
|
+
// original and its workers. When a service was just installed/reparented
|
|
9336
|
+
// (adoptOnly), the launchd/systemd-owned daemon may still be booting, so poll
|
|
9337
|
+
// the socket up to the connect timeout rather than racing to spawn a second,
|
|
9338
|
+
// session-bound daemon that would fight it over the same socket/state.
|
|
9339
|
+
const adoptDeadline = adoptOnly ? Date.now() + SUPERVISOR_CONNECT_TIMEOUT_MS : 0;
|
|
9340
|
+
for (;;) {
|
|
9341
|
+
try {
|
|
9342
|
+
const res = await supervisorRequest({ op: 'status' }, { socketPath, timeoutMs: 500, responseTimeoutMs: SUPERVISOR_PROBE_RESPONSE_TIMEOUT_MS });
|
|
9343
|
+
if (res && res.ok) {
|
|
9344
|
+
// Re-persist the adopted daemon's state so subsequent pid-based checks
|
|
9345
|
+
// (runningSupervisor()) work immediately, instead of staying broken until
|
|
9346
|
+
// some later command happens to heal supervisor.json.
|
|
9347
|
+
const adopted = runningSupervisor() || stateFromStatus(res, socketPath);
|
|
9348
|
+
try { writeSupervisorState(adopted); } catch { /* best effort */ }
|
|
9349
|
+
return adopted;
|
|
9350
|
+
}
|
|
9351
|
+
} catch { /* no live daemon on the socket yet */ }
|
|
9352
|
+
if (Date.now() >= adoptDeadline) break;
|
|
9353
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
9354
|
+
}
|
|
9355
|
+
|
|
9356
|
+
if (adoptOnly) {
|
|
9357
|
+
// A service owns the daemon; never spawn a competing session-bound one —
|
|
9358
|
+
// that is exactly the wedge reparenting exists to avoid.
|
|
9359
|
+
throw new Error(`supervisor service was installed but its daemon did not become ready (see ${supervisorDaemonLogFile()})`);
|
|
9360
|
+
}
|
|
9300
9361
|
|
|
9301
9362
|
clearSupervisorState(); // clear any stale marker from a dead daemon
|
|
9302
9363
|
|
|
@@ -9331,9 +9392,21 @@ async function startSupervisorDaemon() {
|
|
|
9331
9392
|
throw new Error(`supervisor daemon did not become ready (see ${logFile})`);
|
|
9332
9393
|
}
|
|
9333
9394
|
|
|
9395
|
+
/**
|
|
9396
|
+
* Bring the daemon up under the session-independence policy: re-parent into the
|
|
9397
|
+
* persistent launchd domain when macOS-over-SSH is exposed (or a service is
|
|
9398
|
+
* already installed), then adopt the service-owned daemon instead of spawning a
|
|
9399
|
+
* competing session-bound one. Every command that starts the daemon goes through
|
|
9400
|
+
* here, so no path can silently bypass the policy and recreate the logout wedge.
|
|
9401
|
+
*/
|
|
9402
|
+
async function startSupervisorWithServicePolicy(logger = getLogger()) {
|
|
9403
|
+
const serviceOwned = await maybeReparentOrWarnOnStart(logger);
|
|
9404
|
+
return startSupervisorDaemon({ adoptOnly: serviceOwned });
|
|
9405
|
+
}
|
|
9406
|
+
|
|
9334
9407
|
async function supervisorStartCmd(req, flags) {
|
|
9335
9408
|
const logger = getLogger();
|
|
9336
|
-
const state = await
|
|
9409
|
+
const state = await startSupervisorWithServicePolicy(logger);
|
|
9337
9410
|
logger.info(`Supervisor daemon running (pid ${state.pid}).`);
|
|
9338
9411
|
|
|
9339
9412
|
const specs = normalizeArgList(flags?.worker);
|
|
@@ -9418,7 +9491,7 @@ async function supervisorAddCmd(req, flags) {
|
|
|
9418
9491
|
logger.error('--name cannot be combined with --instances > 1 (each instance needs a distinct name); omit --name to auto-name them.');
|
|
9419
9492
|
process.exit(1);
|
|
9420
9493
|
}
|
|
9421
|
-
await
|
|
9494
|
+
await startSupervisorWithServicePolicy(logger);
|
|
9422
9495
|
const workArgs = reconstructWorkArgs(flags);
|
|
9423
9496
|
let added = 0;
|
|
9424
9497
|
let failed = 0;
|
|
@@ -9680,6 +9753,442 @@ async function attachSupervisorConsole(state) {
|
|
|
9680
9753
|
});
|
|
9681
9754
|
}
|
|
9682
9755
|
|
|
9756
|
+
// ---------------------------------------------------------------------------
|
|
9757
|
+
// Session-independent supervisor service (issue #196).
|
|
9758
|
+
//
|
|
9759
|
+
// On macOS a supervisor started over SSH is bound to the SSH login session's
|
|
9760
|
+
// launchd/bootstrap + audit context. On SSH logout macOS tears that per-session
|
|
9761
|
+
// context down and the orphaned daemon + workers lose their network / mDNS
|
|
9762
|
+
// resolution path — the fleet does NOT cleanly exit, it WEDGES (the activation
|
|
9763
|
+
// loop spins on `activateJobs failed: fetch failed` forever and claims zero
|
|
9764
|
+
// jobs). `setsid`/double-fork detachment is not sufficient there: the daemon
|
|
9765
|
+
// must live in a persistent per-user launchd domain (`gui/$UID`).
|
|
9766
|
+
//
|
|
9767
|
+
// Linux is unaffected — under systemd-logind with the default
|
|
9768
|
+
// `KillUserProcesses=no` a `setsid`'d daemon keeps full network access after
|
|
9769
|
+
// logout — so this is a macOS-specific defect in how the supervisor detaches.
|
|
9770
|
+
//
|
|
9771
|
+
// The fix gives the supervisor a session-independent launch path symmetric with
|
|
9772
|
+
// Linux: `supervisor install` writes a per-user LaunchAgent and bootstraps it
|
|
9773
|
+
// into `gui/$UID` (macOS) or a `systemd --user` unit with lingering (Linux),
|
|
9774
|
+
// and `supervisor start` over SSH on macOS auto-reparents into that domain (or,
|
|
9775
|
+
// when it cannot, WARNS that the fleet will die on logout).
|
|
9776
|
+
// ---------------------------------------------------------------------------
|
|
9777
|
+
|
|
9778
|
+
/** True when the current process is running inside an SSH login session. */
|
|
9779
|
+
function isSshSession(env = process.env) {
|
|
9780
|
+
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
|
|
9781
|
+
}
|
|
9782
|
+
|
|
9783
|
+
/** Minimal XML text escaping for the LaunchAgent plist. */
|
|
9784
|
+
function xmlEscape(s) {
|
|
9785
|
+
return String(s)
|
|
9786
|
+
.replace(/&/g, '&')
|
|
9787
|
+
.replace(/</g, '<')
|
|
9788
|
+
.replace(/>/g, '>')
|
|
9789
|
+
.replace(/"/g, '"')
|
|
9790
|
+
.replace(/'/g, ''');
|
|
9791
|
+
}
|
|
9792
|
+
|
|
9793
|
+
// Short hash of the (possibly overridden) state home, so distinct
|
|
9794
|
+
// C8CTL_NANO_HOME instances get distinct services and never fight over the same
|
|
9795
|
+
// launchd label / systemd unit.
|
|
9796
|
+
function supervisorServiceHash() {
|
|
9797
|
+
return createHash('sha1').update(getStateHome()).digest('hex').slice(0, 8);
|
|
9798
|
+
}
|
|
9799
|
+
|
|
9800
|
+
/** Reverse-DNS LaunchAgent label for this state home. */
|
|
9801
|
+
function supervisorServiceLabel() {
|
|
9802
|
+
return `io.nanobpm.c8ctl-nano.supervisor.${supervisorServiceHash()}`;
|
|
9803
|
+
}
|
|
9804
|
+
|
|
9805
|
+
/** Per-user LaunchAgent plist path (macOS). */
|
|
9806
|
+
function launchAgentPlistPath() {
|
|
9807
|
+
return join(homedir(), 'Library', 'LaunchAgents', `${supervisorServiceLabel()}.plist`);
|
|
9808
|
+
}
|
|
9809
|
+
|
|
9810
|
+
/** systemd --user unit file name / path (Linux). */
|
|
9811
|
+
function systemdUnitName() {
|
|
9812
|
+
return `c8ctl-nano-supervisor-${supervisorServiceHash()}.service`;
|
|
9813
|
+
}
|
|
9814
|
+
function systemdUserUnitPath() {
|
|
9815
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
9816
|
+
return join(base, 'systemd', 'user', systemdUnitName());
|
|
9817
|
+
}
|
|
9818
|
+
|
|
9819
|
+
/** launchd domain / service targets. */
|
|
9820
|
+
function launchdDomainTarget(uid) {
|
|
9821
|
+
return `gui/${uid}`;
|
|
9822
|
+
}
|
|
9823
|
+
function launchdServiceTarget(uid, label) {
|
|
9824
|
+
return `gui/${uid}/${label}`;
|
|
9825
|
+
}
|
|
9826
|
+
|
|
9827
|
+
// Curate the env a persistent service should inherit — enough to re-invoke the
|
|
9828
|
+
// CLI and locate its state, but NOT the whole SSH environment (which could bleed
|
|
9829
|
+
// short-lived tokens into a persistent plist/unit that outlives the session).
|
|
9830
|
+
const SUPERVISOR_SERVICE_ENV_KEYS = ['PATH', 'HOME', 'LANG', 'LC_ALL', 'C8CTL_NANO_HOME'];
|
|
9831
|
+
function supervisorServiceEnv(env = process.env) {
|
|
9832
|
+
const out = {};
|
|
9833
|
+
for (const k of SUPERVISOR_SERVICE_ENV_KEYS) {
|
|
9834
|
+
if (env[k] != null && env[k] !== '') out[k] = String(env[k]);
|
|
9835
|
+
}
|
|
9836
|
+
// Pin the entry point so the daemon can spawn `work` children even if argv[1]
|
|
9837
|
+
// differs under launchd/systemd.
|
|
9838
|
+
const { entry } = c8ctlInvocation();
|
|
9839
|
+
if (entry) out.C8CTL_NANO_ENTRY = entry;
|
|
9840
|
+
return out;
|
|
9841
|
+
}
|
|
9842
|
+
|
|
9843
|
+
/**
|
|
9844
|
+
* Build a per-user LaunchAgent plist that runs `nano supervisor __daemon`.
|
|
9845
|
+
* `RunAtLoad` starts it at login/reboot; `KeepAlive`={SuccessfulExit:false}
|
|
9846
|
+
* restarts it only on a crash — an intentional `supervisor stop` exits 0 and
|
|
9847
|
+
* stays down, so the invariant "stop stops the fleet" is preserved.
|
|
9848
|
+
*/
|
|
9849
|
+
function buildLaunchAgentPlist({ label, exec, entry, env = {}, stdoutPath, stderrPath }) {
|
|
9850
|
+
const args = [exec, entry, 'nano', 'supervisor', '__daemon'];
|
|
9851
|
+
const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
|
9852
|
+
const envXml = Object.entries(env)
|
|
9853
|
+
.filter(([, v]) => v != null && v !== '')
|
|
9854
|
+
.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(String(v))}</string>`)
|
|
9855
|
+
.join('\n');
|
|
9856
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
9857
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
9858
|
+
<plist version="1.0">
|
|
9859
|
+
<dict>
|
|
9860
|
+
<key>Label</key>
|
|
9861
|
+
<string>${xmlEscape(label)}</string>
|
|
9862
|
+
<key>ProgramArguments</key>
|
|
9863
|
+
<array>
|
|
9864
|
+
${argXml}
|
|
9865
|
+
</array>
|
|
9866
|
+
<key>EnvironmentVariables</key>
|
|
9867
|
+
<dict>
|
|
9868
|
+
${envXml}
|
|
9869
|
+
</dict>
|
|
9870
|
+
<key>RunAtLoad</key>
|
|
9871
|
+
<true/>
|
|
9872
|
+
<key>KeepAlive</key>
|
|
9873
|
+
<dict>
|
|
9874
|
+
<key>SuccessfulExit</key>
|
|
9875
|
+
<false/>
|
|
9876
|
+
</dict>
|
|
9877
|
+
<key>ProcessType</key>
|
|
9878
|
+
<string>Background</string>
|
|
9879
|
+
<key>StandardOutPath</key>
|
|
9880
|
+
<string>${xmlEscape(stdoutPath)}</string>
|
|
9881
|
+
<key>StandardErrorPath</key>
|
|
9882
|
+
<string>${xmlEscape(stderrPath)}</string>
|
|
9883
|
+
</dict>
|
|
9884
|
+
</plist>
|
|
9885
|
+
`;
|
|
9886
|
+
}
|
|
9887
|
+
|
|
9888
|
+
/**
|
|
9889
|
+
* Quote one `ExecStart` argument for a systemd unit. systemd splits the command
|
|
9890
|
+
* line on unquoted whitespace, so any argument containing whitespace or a shell
|
|
9891
|
+
* metacharacter must be double-quoted with C-style escaping inside. A literal
|
|
9892
|
+
* `%` is doubled so systemd never mistakes it for a specifier (e.g. `%h`).
|
|
9893
|
+
*/
|
|
9894
|
+
function systemdQuoteExecArg(arg) {
|
|
9895
|
+
const s = String(arg).replace(/%/g, '%%');
|
|
9896
|
+
if (s === '') return '""';
|
|
9897
|
+
if (/[\s"'\\`$;&|<>()]/.test(String(arg))) {
|
|
9898
|
+
return `"${s.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
|
|
9899
|
+
}
|
|
9900
|
+
return s;
|
|
9901
|
+
}
|
|
9902
|
+
|
|
9903
|
+
/**
|
|
9904
|
+
* Render one `Environment=` line for a systemd unit. When the `KEY=VALUE`
|
|
9905
|
+
* assignment contains whitespace or a quote/backslash, the whole assignment is
|
|
9906
|
+
* double-quoted (systemd otherwise splits it into multiple assignments on
|
|
9907
|
+
* whitespace). A literal `%` is doubled so it is not expanded as a specifier.
|
|
9908
|
+
*/
|
|
9909
|
+
function systemdEnvLine(k, v) {
|
|
9910
|
+
const key = String(k).replace(/%/g, '%%');
|
|
9911
|
+
const val = String(v).replace(/%/g, '%%');
|
|
9912
|
+
const assignment = `${key}=${val}`;
|
|
9913
|
+
if (/[\s"'\\`$]/.test(`${k}=${v}`)) {
|
|
9914
|
+
return `Environment="${assignment.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
|
|
9915
|
+
}
|
|
9916
|
+
return `Environment=${assignment}`;
|
|
9917
|
+
}
|
|
9918
|
+
|
|
9919
|
+
/**
|
|
9920
|
+
* Build a `systemd --user` unit that runs `nano supervisor __daemon`.
|
|
9921
|
+
* `Restart=on-failure` mirrors the launchd KeepAlive: a crash is restarted, an
|
|
9922
|
+
* intentional `supervisor stop` (clean exit) stays down. `exec`/`entry` and env
|
|
9923
|
+
* values are quoted/escaped so paths or values with whitespace or quotes don't
|
|
9924
|
+
* make systemd misparse the line and fail to start the service.
|
|
9925
|
+
*/
|
|
9926
|
+
function buildSystemdUserUnit({ exec, entry, env = {} }) {
|
|
9927
|
+
const cmd = `${systemdQuoteExecArg(exec)} ${systemdQuoteExecArg(entry)} nano supervisor __daemon`;
|
|
9928
|
+
const envLines = Object.entries(env)
|
|
9929
|
+
.filter(([, v]) => v != null && v !== '')
|
|
9930
|
+
.map(([k, v]) => systemdEnvLine(k, v))
|
|
9931
|
+
.join('\n');
|
|
9932
|
+
return `[Unit]
|
|
9933
|
+
Description=c8ctl nano worker supervisor
|
|
9934
|
+
After=network-online.target
|
|
9935
|
+
Wants=network-online.target
|
|
9936
|
+
|
|
9937
|
+
[Service]
|
|
9938
|
+
Type=simple
|
|
9939
|
+
ExecStart=${cmd}
|
|
9940
|
+
${envLines}
|
|
9941
|
+
Restart=on-failure
|
|
9942
|
+
RestartSec=5
|
|
9943
|
+
|
|
9944
|
+
[Install]
|
|
9945
|
+
WantedBy=default.target
|
|
9946
|
+
`;
|
|
9947
|
+
}
|
|
9948
|
+
|
|
9949
|
+
/** Whether a persistent supervisor service is installed for this state home. */
|
|
9950
|
+
function supervisorServiceInstalled(platform = osPlatform()) {
|
|
9951
|
+
if (platform === 'darwin') return existsSync(launchAgentPlistPath());
|
|
9952
|
+
if (platform === 'linux') return existsSync(systemdUserUnitPath());
|
|
9953
|
+
return false;
|
|
9954
|
+
}
|
|
9955
|
+
|
|
9956
|
+
/**
|
|
9957
|
+
* Pure predicate: should `supervisor start` warn about SSH-logout teardown?
|
|
9958
|
+
* Only macOS over SSH without an installed service is exposed to the wedge.
|
|
9959
|
+
*/
|
|
9960
|
+
function shouldWarnSshTeardown({
|
|
9961
|
+
platform = osPlatform(),
|
|
9962
|
+
env = process.env,
|
|
9963
|
+
installed = supervisorServiceInstalled(platform),
|
|
9964
|
+
} = {}) {
|
|
9965
|
+
return platform === 'darwin' && isSshSession(env) && !installed;
|
|
9966
|
+
}
|
|
9967
|
+
|
|
9968
|
+
function warnSshTeardown(logger) {
|
|
9969
|
+
logger.warn('⚠ macOS + SSH: this supervisor is bound to your SSH login session.');
|
|
9970
|
+
logger.warn(' When you log out, macOS tears that session down and the fleet WEDGES —');
|
|
9971
|
+
logger.warn(' workers keep showing "running" but stop claiming jobs (the activation');
|
|
9972
|
+
logger.warn(' loop spins on: SDK activateJobs failed: fetch failed).');
|
|
9973
|
+
logger.warn(' Install a session-independent service so it survives logout:');
|
|
9974
|
+
logger.warn(' c8ctl nano supervisor install');
|
|
9975
|
+
}
|
|
9976
|
+
|
|
9977
|
+
function runLaunchctl(args) {
|
|
9978
|
+
try {
|
|
9979
|
+
const r = spawnSync('launchctl', args, { encoding: 'utf8', timeout: 15_000 });
|
|
9980
|
+
return { code: r.status, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim(), error: r.error };
|
|
9981
|
+
} catch (err) {
|
|
9982
|
+
return { code: null, stdout: '', stderr: '', error: err };
|
|
9983
|
+
}
|
|
9984
|
+
}
|
|
9985
|
+
|
|
9986
|
+
function runSystemctlUser(args) {
|
|
9987
|
+
try {
|
|
9988
|
+
const r = spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8', timeout: 15_000 });
|
|
9989
|
+
return { code: r.status, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim(), error: r.error };
|
|
9990
|
+
} catch (err) {
|
|
9991
|
+
return { code: null, stdout: '', stderr: '', error: err };
|
|
9992
|
+
}
|
|
9993
|
+
}
|
|
9994
|
+
|
|
9995
|
+
function systemdUserAvailable() {
|
|
9996
|
+
return runSystemctlUser(['--version']).code === 0;
|
|
9997
|
+
}
|
|
9998
|
+
|
|
9999
|
+
/**
|
|
10000
|
+
* Stop a running daemon (socket `stop` → SIGTERM → SIGKILL the group), used
|
|
10001
|
+
* before installing a service so the launchd/systemd-owned daemon takes over
|
|
10002
|
+
* the deterministic control socket without a second instance fighting for it.
|
|
10003
|
+
*/
|
|
10004
|
+
async function stopSupervisorProcess(running) {
|
|
10005
|
+
try { await supervisorRequest({ op: 'stop' }); }
|
|
10006
|
+
catch { try { process.kill(running.pid, 'SIGTERM'); } catch { /* already gone */ } }
|
|
10007
|
+
const deadline = Date.now() + STOP_GRACE_MS + 2_000;
|
|
10008
|
+
while (Date.now() < deadline) {
|
|
10009
|
+
if (!isPidAlive(running.pid)) break;
|
|
10010
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
10011
|
+
}
|
|
10012
|
+
if (isPidAlive(running.pid)) {
|
|
10013
|
+
if (osPlatform() !== 'win32') {
|
|
10014
|
+
try { process.kill(-running.pid, 'SIGKILL'); }
|
|
10015
|
+
catch { try { process.kill(running.pid, 'SIGKILL'); } catch { /* ignore */ } }
|
|
10016
|
+
} else {
|
|
10017
|
+
try { process.kill(running.pid, 'SIGKILL'); } catch { /* ignore */ }
|
|
10018
|
+
}
|
|
10019
|
+
}
|
|
10020
|
+
clearSupervisorState();
|
|
10021
|
+
}
|
|
10022
|
+
|
|
10023
|
+
async function installSupervisorServiceDarwin(logger) {
|
|
10024
|
+
const uid = process.getuid();
|
|
10025
|
+
const label = supervisorServiceLabel();
|
|
10026
|
+
const plistPath = launchAgentPlistPath();
|
|
10027
|
+
const { exec, entry } = c8ctlInvocation();
|
|
10028
|
+
if (!entry) { logger.error('Cannot resolve the c8ctl entry point to install the service.'); return false; }
|
|
10029
|
+
|
|
10030
|
+
// Hand the socket to the launchd-owned daemon: stop any session-bound one.
|
|
10031
|
+
const running = await liveSupervisor();
|
|
10032
|
+
if (running) {
|
|
10033
|
+
logger.info('Stopping the current session-bound supervisor before installing the service…');
|
|
10034
|
+
await stopSupervisorProcess(running);
|
|
10035
|
+
}
|
|
10036
|
+
|
|
10037
|
+
mkdirSync(dirname(plistPath), { recursive: true });
|
|
10038
|
+
mkdirSync(getSupervisorLogDir(), { recursive: true });
|
|
10039
|
+
const daemonLog = supervisorDaemonLogFile();
|
|
10040
|
+
writeFileSync(plistPath, buildLaunchAgentPlist({
|
|
10041
|
+
label, exec, entry, env: supervisorServiceEnv(), stdoutPath: daemonLog, stderrPath: daemonLog,
|
|
10042
|
+
}));
|
|
10043
|
+
|
|
10044
|
+
runLaunchctl(['bootout', launchdServiceTarget(uid, label)]); // best effort: clear any prior instance
|
|
10045
|
+
const boot = runLaunchctl(['bootstrap', launchdDomainTarget(uid), plistPath]);
|
|
10046
|
+
if (boot.code !== 0 && !/already (loaded|bootstrapped)/i.test(boot.stderr)) {
|
|
10047
|
+
logger.error(`launchctl bootstrap failed: ${boot.stderr || boot.error?.message || 'unknown error'}`);
|
|
10048
|
+
return false;
|
|
10049
|
+
}
|
|
10050
|
+
runLaunchctl(['enable', launchdServiceTarget(uid, label)]);
|
|
10051
|
+
runLaunchctl(['kickstart', '-k', launchdServiceTarget(uid, label)]);
|
|
10052
|
+
logger.info(`Installed LaunchAgent ${label} into ${launchdDomainTarget(uid)}.`);
|
|
10053
|
+
logger.info(` plist: ${plistPath}`);
|
|
10054
|
+
logger.info('The supervisor now survives SSH logout and restarts at login/reboot.');
|
|
10055
|
+
logger.info('Manage it with: c8ctl nano supervisor status | add | stop | uninstall');
|
|
10056
|
+
return true;
|
|
10057
|
+
}
|
|
10058
|
+
|
|
10059
|
+
function uninstallSupervisorServiceDarwin(logger) {
|
|
10060
|
+
const uid = process.getuid();
|
|
10061
|
+
const label = supervisorServiceLabel();
|
|
10062
|
+
const plistPath = launchAgentPlistPath();
|
|
10063
|
+
const existed = existsSync(plistPath);
|
|
10064
|
+
runLaunchctl(['bootout', launchdServiceTarget(uid, label)]); // stops + unloads
|
|
10065
|
+
try { if (existed) rmSync(plistPath, { force: true }); } catch { /* best effort */ }
|
|
10066
|
+
clearSupervisorState();
|
|
10067
|
+
if (existed) logger.info(`Removed LaunchAgent ${label}.`);
|
|
10068
|
+
else logger.info('No LaunchAgent was installed for this state home.');
|
|
10069
|
+
return true;
|
|
10070
|
+
}
|
|
10071
|
+
|
|
10072
|
+
async function installSupervisorServiceLinux(logger) {
|
|
10073
|
+
if (!systemdUserAvailable()) {
|
|
10074
|
+
logger.info('systemd --user is not available on this host.');
|
|
10075
|
+
logger.info('No service is required: the supervisor already detaches with setsid, and under');
|
|
10076
|
+
logger.info('systemd-logind with the default KillUserProcesses=no it survives SSH logout.');
|
|
10077
|
+
return true;
|
|
10078
|
+
}
|
|
10079
|
+
const { exec, entry } = c8ctlInvocation();
|
|
10080
|
+
if (!entry) { logger.error('Cannot resolve the c8ctl entry point to install the service.'); return false; }
|
|
10081
|
+
|
|
10082
|
+
const running = await liveSupervisor();
|
|
10083
|
+
if (running) {
|
|
10084
|
+
logger.info('Stopping the current supervisor before installing the service…');
|
|
10085
|
+
await stopSupervisorProcess(running);
|
|
10086
|
+
}
|
|
10087
|
+
|
|
10088
|
+
const unitPath = systemdUserUnitPath();
|
|
10089
|
+
mkdirSync(dirname(unitPath), { recursive: true });
|
|
10090
|
+
writeFileSync(unitPath, buildSystemdUserUnit({ exec, entry, env: supervisorServiceEnv() }));
|
|
10091
|
+
|
|
10092
|
+
// Enable lingering so the user manager (and the daemon) survive logout even
|
|
10093
|
+
// where KillUserProcesses=yes.
|
|
10094
|
+
const user = process.env.USER || process.env.LOGNAME;
|
|
10095
|
+
try { spawnSync('loginctl', user ? ['enable-linger', user] : ['enable-linger'], { timeout: 10_000 }); }
|
|
10096
|
+
catch { /* best effort */ }
|
|
10097
|
+
|
|
10098
|
+
runSystemctlUser(['daemon-reload']);
|
|
10099
|
+
const en = runSystemctlUser(['enable', '--now', systemdUnitName()]);
|
|
10100
|
+
if (en.code !== 0) { logger.error(`systemctl --user enable failed: ${en.stderr || en.error?.message || 'unknown error'}`); return false; }
|
|
10101
|
+
logger.info(`Installed systemd --user unit ${systemdUnitName()} (enabled + started).`);
|
|
10102
|
+
logger.info(` unit: ${unitPath}`);
|
|
10103
|
+
logger.info('Lingering is enabled so the fleet survives logout and starts at boot.');
|
|
10104
|
+
logger.info('Manage it with: c8ctl nano supervisor status | add | stop | uninstall');
|
|
10105
|
+
return true;
|
|
10106
|
+
}
|
|
10107
|
+
|
|
10108
|
+
function uninstallSupervisorServiceLinux(logger) {
|
|
10109
|
+
const unitPath = systemdUserUnitPath();
|
|
10110
|
+
const existed = existsSync(unitPath);
|
|
10111
|
+
if (systemdUserAvailable()) runSystemctlUser(['disable', '--now', systemdUnitName()]);
|
|
10112
|
+
try { if (existed) rmSync(unitPath, { force: true }); } catch { /* best effort */ }
|
|
10113
|
+
if (systemdUserAvailable()) runSystemctlUser(['daemon-reload']);
|
|
10114
|
+
clearSupervisorState();
|
|
10115
|
+
if (existed) logger.info(`Removed systemd --user unit ${systemdUnitName()}.`);
|
|
10116
|
+
else logger.info('No systemd --user unit was installed for this state home.');
|
|
10117
|
+
return true;
|
|
10118
|
+
}
|
|
10119
|
+
|
|
10120
|
+
async function supervisorInstallCmd() {
|
|
10121
|
+
const logger = getLogger();
|
|
10122
|
+
const plat = osPlatform();
|
|
10123
|
+
if (plat === 'darwin') { await installSupervisorServiceDarwin(logger); return; }
|
|
10124
|
+
if (plat === 'linux') { await installSupervisorServiceLinux(logger); return; }
|
|
10125
|
+
logger.error(`supervisor install is not supported on ${plat}.`);
|
|
10126
|
+
}
|
|
10127
|
+
|
|
10128
|
+
async function supervisorUninstallCmd() {
|
|
10129
|
+
const logger = getLogger();
|
|
10130
|
+
const plat = osPlatform();
|
|
10131
|
+
if (plat === 'darwin') { uninstallSupervisorServiceDarwin(logger); return; }
|
|
10132
|
+
if (plat === 'linux') { uninstallSupervisorServiceLinux(logger); return; }
|
|
10133
|
+
logger.error(`supervisor uninstall is not supported on ${plat}.`);
|
|
10134
|
+
}
|
|
10135
|
+
|
|
10136
|
+
/**
|
|
10137
|
+
* Start an already-installed LaunchAgent when it is not running, so a
|
|
10138
|
+
* service-owned start can adopt it. `RunAtLoad` only fires at bootstrap/login and
|
|
10139
|
+
* `KeepAlive` is crash-only, so nothing brings the daemon back after a clean
|
|
10140
|
+
* `supervisor stop` — adopt-only would poll an empty socket and fail. Plain
|
|
10141
|
+
* `kickstart` (never `-k`) is a no-op when the service is up, so a live fleet is
|
|
10142
|
+
* never bounced; a service launchd no longer has loaded is re-bootstrapped from
|
|
10143
|
+
* its plist. `run`/`uid`/`label`/`plistPath` are injectable for deterministic tests.
|
|
10144
|
+
*/
|
|
10145
|
+
function ensureLaunchAgentStarted(logger, { run = runLaunchctl, uid, label, plistPath } = {}) {
|
|
10146
|
+
const svcUid = uid ?? (typeof process.getuid === 'function' ? process.getuid() : 0);
|
|
10147
|
+
const target = launchdServiceTarget(svcUid, label ?? supervisorServiceLabel());
|
|
10148
|
+
let kick = run(['kickstart', target]);
|
|
10149
|
+
if (kick.code === 0) return true;
|
|
10150
|
+
|
|
10151
|
+
// Not loaded in the domain (a `bootout` without uninstall, or a login that
|
|
10152
|
+
// predated the plist): reload it from disk. `bootstrap`'s own status is not the
|
|
10153
|
+
// verdict — an already-loaded service reports a non-zero "Input/output error" —
|
|
10154
|
+
// so re-enable, kick again, and let that decide.
|
|
10155
|
+
run(['bootstrap', launchdDomainTarget(svcUid), plistPath ?? launchAgentPlistPath()]);
|
|
10156
|
+
run(['enable', target]);
|
|
10157
|
+
kick = run(['kickstart', target]);
|
|
10158
|
+
if (kick.code === 0) return true;
|
|
10159
|
+
logger.warn(`launchctl kickstart failed: ${kick.stderr || kick.error?.message || 'unknown error'}`);
|
|
10160
|
+
return false;
|
|
10161
|
+
}
|
|
10162
|
+
|
|
10163
|
+
/**
|
|
10164
|
+
* On a macOS `supervisor start`/`attach` over SSH, re-parent the daemon into the
|
|
10165
|
+
* persistent `gui/$UID` launchd domain (via an installed LaunchAgent) so it
|
|
10166
|
+
* survives logout instead of dying with the SSH session. If we cannot (or the
|
|
10167
|
+
* operator opts out with C8CTL_NANO_NO_LAUNCHD), warn loudly and fall through to
|
|
10168
|
+
* the ordinary detached spawn. Returns `true` when a service owns the daemon
|
|
10169
|
+
* (already installed, or just reparented) so the caller adopts it instead of
|
|
10170
|
+
* spawning a competing session-bound daemon; `false` on every non-exposed path
|
|
10171
|
+
* (Linux, a local login, opt-out/failure, or a service that would not start).
|
|
10172
|
+
*/
|
|
10173
|
+
async function maybeReparentOrWarnOnStart(logger) {
|
|
10174
|
+
if (osPlatform() !== 'darwin') return false; // Linux already survives logout
|
|
10175
|
+
if (supervisorServiceInstalled()) {
|
|
10176
|
+
// An installed service owns the daemon — but make sure it is actually up
|
|
10177
|
+
// first, or adopt-only would time out on the empty socket a clean stop left.
|
|
10178
|
+
if (await liveSupervisor()) return true;
|
|
10179
|
+
if (ensureLaunchAgentStarted(logger)) return true;
|
|
10180
|
+
logger.warn('The installed supervisor service would not start; falling back to a detached (session-bound) daemon.');
|
|
10181
|
+
return false;
|
|
10182
|
+
}
|
|
10183
|
+
if (!isSshSession()) return false; // a local login session isn't torn down like SSH
|
|
10184
|
+
if (await liveSupervisor()) return false; // adopting an existing daemon — nothing to re-parent
|
|
10185
|
+
if (coerceBool(process.env.C8CTL_NANO_NO_LAUNCHD, false)) { warnSshTeardown(logger); return false; }
|
|
10186
|
+
logger.info('macOS + SSH detected — re-parenting the supervisor into the gui launchd domain so it survives logout…');
|
|
10187
|
+
const ok = await installSupervisorServiceDarwin(logger);
|
|
10188
|
+
if (!ok) { warnSshTeardown(logger); return false; }
|
|
10189
|
+
return true; // the launchd service now owns the daemon — adopt it, don't spawn
|
|
10190
|
+
}
|
|
10191
|
+
|
|
9683
10192
|
/** Dispatch the `supervisor` subcommand's action. */
|
|
9684
10193
|
async function supervisorCommand(req, flags) {
|
|
9685
10194
|
const action = (req.positional[0] || '').toLowerCase();
|
|
@@ -9689,13 +10198,21 @@ async function supervisorCommand(req, flags) {
|
|
|
9689
10198
|
return;
|
|
9690
10199
|
case '':
|
|
9691
10200
|
case 'attach': {
|
|
9692
|
-
|
|
10201
|
+
// Same session-independence policy as `supervisor start`: a bare `attach`
|
|
10202
|
+
// must not spawn a session-bound daemon that an SSH logout wedges.
|
|
10203
|
+
const state = await startSupervisorWithServicePolicy();
|
|
9693
10204
|
await attachSupervisorConsole(runningSupervisor() || state);
|
|
9694
10205
|
return;
|
|
9695
10206
|
}
|
|
9696
10207
|
case 'start':
|
|
9697
10208
|
await supervisorStartCmd(req, flags);
|
|
9698
10209
|
return;
|
|
10210
|
+
case 'install':
|
|
10211
|
+
await supervisorInstallCmd();
|
|
10212
|
+
return;
|
|
10213
|
+
case 'uninstall':
|
|
10214
|
+
await supervisorUninstallCmd();
|
|
10215
|
+
return;
|
|
9699
10216
|
case 'status':
|
|
9700
10217
|
case 'list':
|
|
9701
10218
|
case 'ls':
|
|
@@ -9719,7 +10236,7 @@ async function supervisorCommand(req, flags) {
|
|
|
9719
10236
|
supervisorLogsCmd(req);
|
|
9720
10237
|
return;
|
|
9721
10238
|
default:
|
|
9722
|
-
getLogger().error(`Unknown supervisor action "${action}". Use: start|status|add|remove|restart|stop|logs|attach`);
|
|
10239
|
+
getLogger().error(`Unknown supervisor action "${action}". Use: start|install|uninstall|status|add|remove|restart|stop|logs|attach`);
|
|
9723
10240
|
process.exit(1);
|
|
9724
10241
|
}
|
|
9725
10242
|
}
|
|
@@ -10513,7 +11030,7 @@ async function workforceStartCmd(req, flags, manifestName) {
|
|
|
10513
11030
|
process.exit(1);
|
|
10514
11031
|
}
|
|
10515
11032
|
|
|
10516
|
-
const state = await
|
|
11033
|
+
const state = await startSupervisorWithServicePolicy(logger);
|
|
10517
11034
|
logger.info(`Supervisor daemon running (pid ${state.pid}).`);
|
|
10518
11035
|
const { reachable, workers: live } = await fetchSupervisorWorkers();
|
|
10519
11036
|
// A running daemon with an unreachable status socket reports `live: []`, which
|
|
@@ -12508,6 +13025,20 @@ export {
|
|
|
12508
13025
|
clearSupervisorState,
|
|
12509
13026
|
getSupervisorSocketPath,
|
|
12510
13027
|
getSupervisorStateFile,
|
|
13028
|
+
isSshSession,
|
|
13029
|
+
xmlEscape,
|
|
13030
|
+
supervisorServiceLabel,
|
|
13031
|
+
launchAgentPlistPath,
|
|
13032
|
+
systemdUnitName,
|
|
13033
|
+
systemdUserUnitPath,
|
|
13034
|
+
launchdDomainTarget,
|
|
13035
|
+
launchdServiceTarget,
|
|
13036
|
+
supervisorServiceEnv,
|
|
13037
|
+
buildLaunchAgentPlist,
|
|
13038
|
+
buildSystemdUserUnit,
|
|
13039
|
+
supervisorServiceInstalled,
|
|
13040
|
+
shouldWarnSshTeardown,
|
|
13041
|
+
ensureLaunchAgentStarted,
|
|
12511
13042
|
};
|
|
12512
13043
|
|
|
12513
13044
|
export {
|
|
@@ -12588,6 +13119,8 @@ export const metadata = {
|
|
|
12588
13119
|
{ command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
|
|
12589
13120
|
{ command: 'NANO_AGENTIC_URL=http://localhost:8080 NANO_AGENTIC_SECRET=<shared-secret> c8ctl nano work reviewer', description: 'Enrol the worker on the app\'s same-port /agentic channel in SECURE mode (same NANO_AGENTIC_SECRET as the server) so it appears live (presence + relay terminals) on the Workforce visibility page' },
|
|
12590
13121
|
{ command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
|
|
13122
|
+
{ command: 'c8ctl nano supervisor install', description: 'Install a session-independent supervisor service (macOS LaunchAgent in gui/$UID; Linux systemd --user + lingering) so the fleet survives SSH logout and returns at login/reboot' },
|
|
13123
|
+
{ command: 'c8ctl nano supervisor uninstall', description: 'Remove the installed supervisor service (LaunchAgent / systemd --user unit)' },
|
|
12591
13124
|
{ command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
|
|
12592
13125
|
{ command: 'c8ctl nano supervisor status', description: 'List supervised workers (state, ENGINE + AGENTIC visibility diagnostics, serviced job / idle, pid, restarts, uptime) without the console' },
|
|
12593
13126
|
{ command: 'c8ctl nano supervisor add decider', description: 'Add a supervised worker (forwarding work flags) to the running supervisor' },
|
|
@@ -12832,7 +13365,7 @@ function printUsage() {
|
|
|
12832
13365
|
console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--arg <switch> ...] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--terminal pty|pipe] [--protocol pipe|acp] [--permission yolo|escalate|filter] [--env NAME=VALUE ...] [--list]');
|
|
12833
13366
|
console.log(' c8ctl nano assign <profileName> <cap[,cap...]> [--name <n>] [--capabilities <a,b>]');
|
|
12834
13367
|
console.log(' c8ctl nano work <profileName> [--auto [--auto-scope <p>]] [--arg <switch> ...] [--recovery-window <ms>] [--idle-timeout <ms>] [--job-timeout <ms>] [--poll-timeout <ms>] [--job-type <token> ...] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
|
|
12835
|
-
console.log(' c8ctl nano supervisor [start|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
|
|
13368
|
+
console.log(' c8ctl nano supervisor [start|install|uninstall|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
|
|
12836
13369
|
console.log(' c8ctl nano workforce [add|remove|list|start|status|stop] ... [--manifest <manifest>] (declarative, reusable fleet manifests)');
|
|
12837
13370
|
console.log('');
|
|
12838
13371
|
console.log('Subcommands:');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.56.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
|
|
6
6
|
"main": "c8ctl-plugin.js",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"files": [
|
|
23
23
|
"c8ctl-plugin.js",
|
|
24
|
+
"agent-instance.mjs",
|
|
24
25
|
"platforms.mjs",
|
|
25
26
|
"agentic.mjs",
|
|
26
27
|
"agentic-loader-hook.mjs",
|
|
@@ -71,12 +72,12 @@
|
|
|
71
72
|
},
|
|
72
73
|
"optionalDependencies": {
|
|
73
74
|
"node-pty": "^1.0.0",
|
|
74
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
75
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
76
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
77
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
78
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
79
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
80
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
75
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.56.1",
|
|
76
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.56.1",
|
|
77
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.56.1",
|
|
78
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.56.1",
|
|
79
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.56.1",
|
|
80
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.56.1",
|
|
81
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.56.1"
|
|
81
82
|
}
|
|
82
83
|
}
|
package/supervisor-engine.mjs
CHANGED
|
@@ -140,6 +140,18 @@ function mapJob(raw) {
|
|
|
140
140
|
}
|
|
141
141
|
const pik = raw.processInstanceKey;
|
|
142
142
|
if (pik !== undefined && pik !== null) job.processInstanceKey = String(pik);
|
|
143
|
+
// Engine-native AgentInstance attribution (issue #194): the element instance the
|
|
144
|
+
// AgentInstance correlates on, the element id, and the opaque per-activation
|
|
145
|
+
// `jobLease` token that lease-gates a `createAgentInstance` for an `external`
|
|
146
|
+
// agent job (nanobpmn #1099/#1106). These ride opaquely to the runner exactly as
|
|
147
|
+
// the SDK activation surfaces them; absent for ordinary (non-agent) jobs, in which
|
|
148
|
+
// case the durable-transcript producer stays inert.
|
|
149
|
+
const eik = raw.elementInstanceKey;
|
|
150
|
+
if (eik !== undefined && eik !== null) job.elementInstanceKey = String(eik);
|
|
151
|
+
const eid = raw.elementId;
|
|
152
|
+
if (eid !== undefined && eid !== null) job.elementId = String(eid);
|
|
153
|
+
const lease = raw.jobLease;
|
|
154
|
+
if (lease !== undefined && lease !== null) job.jobLease = String(lease);
|
|
143
155
|
return job;
|
|
144
156
|
}
|
|
145
157
|
|