c8ctl-plugin-nano 1.61.2 → 1.62.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 +22 -4
- package/agent-instance.mjs +44 -0
- package/agentic.mjs +4 -2
- package/c8ctl-plugin.js +500 -85
- package/package.json +8 -8
- package/supervisor.dist.js +4 -4
package/c8ctl-plugin.js
CHANGED
|
@@ -3089,84 +3089,191 @@ function resolveAutoRestConfig(camunda, env = process.env) {
|
|
|
3089
3089
|
// exist" is answerable from that engine alone.
|
|
3090
3090
|
//
|
|
3091
3091
|
// `@nanobpm/agentic/demand` already reads deployed `taskDefinition` leaves over
|
|
3092
|
-
// C8 REST (`process-definitions/search` → `/{key}/xml`)
|
|
3093
|
-
//
|
|
3094
|
-
//
|
|
3095
|
-
// `<zeebe:
|
|
3096
|
-
// `
|
|
3097
|
-
//
|
|
3098
|
-
//
|
|
3099
|
-
// `linkName="prompt"` linked resource, and no in-process worker task does. Not
|
|
3100
|
-
// every service task is an agent task — plain connectors and record-keepers
|
|
3101
|
-
// (e.g. `pr.record-plan`) are ordinary workers, and they carry no prompt link.
|
|
3092
|
+
// C8 REST (`process-definitions/search` → `/{key}/xml`): `scanTaskDefinitions(xml)`
|
|
3093
|
+
// yields every service-task leaf with its `taskType`/`process`/`elementId`. Since
|
|
3094
|
+
// issue #235 the SINGLE auto-discovery convention is the external-agent marker
|
|
3095
|
+
// `<zeebe:agentDefinition agentType="external">` (see below), not the earlier
|
|
3096
|
+
// linked-prompt `agentic` flag or the legacy `io.nanobpm.agentTask` header. Not
|
|
3097
|
+
// every service task is an agent task — plain connectors and record-keepers (e.g.
|
|
3098
|
+
// `pr.record-plan`) carry no marker and are ordinary workers.
|
|
3102
3099
|
//
|
|
3103
|
-
// Per AGENTS.md "Derivation Over Duplication: No Drift Surfaces",
|
|
3104
|
-
//
|
|
3105
|
-
//
|
|
3106
|
-
//
|
|
3107
|
-
//
|
|
3108
|
-
//
|
|
3109
|
-
// dot-grammar.
|
|
3100
|
+
// Per AGENTS.md "Derivation Over Duplication: No Drift Surfaces", the authoritative
|
|
3101
|
+
// task-type / process derivation still comes from `scanTaskDefinitions`; the plugin
|
|
3102
|
+
// only supplements the "is this an external agent task?" answer locally until the
|
|
3103
|
+
// package exposes an `external` leaf flag (`scanAgentTaskLeaves` prefers it when
|
|
3104
|
+
// present). Advertise the raw job-type string the engine matches (`senior:plan`)
|
|
3105
|
+
// verbatim — colon-named types are NOT forced through the agentic dot-grammar.
|
|
3110
3106
|
// ---------------------------------------------------------------------------
|
|
3111
3107
|
|
|
3112
|
-
//
|
|
3113
|
-
//
|
|
3114
|
-
// `linkName="prompt"` linked resource. The current package detector
|
|
3115
|
-
// (`scanTaskDefinitions`, whose `agentic` flag keys SOLELY off the linked-prompt
|
|
3116
|
-
// side-car) therefore reports `agentic:false` for such tasks. We keep a narrow,
|
|
3117
|
-
// self-contained fallback so `--auto` still discovers agent job types against an
|
|
3118
|
-
// engine still holding a pre-#203 deployment (issue #120 acceptance criterion:
|
|
3119
|
-
// "Legacy header-based BPMN still discovers correctly"). This is a supplement to
|
|
3120
|
-
// — never a replacement for — the package flag: the authoritative task-type /
|
|
3121
|
-
// process derivation still comes from `scanTaskDefinitions`; this only answers
|
|
3122
|
-
// "is this leaf an agent task?" for the legacy shape.
|
|
3108
|
+
// Single-convention agent-task marker (issue #235): every external agent task
|
|
3109
|
+
// carries the ONE canonical eligibility flag
|
|
3123
3110
|
//
|
|
3124
|
-
//
|
|
3125
|
-
//
|
|
3126
|
-
//
|
|
3127
|
-
//
|
|
3128
|
-
//
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
)
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
//
|
|
3138
|
-
//
|
|
3139
|
-
//
|
|
3140
|
-
//
|
|
3141
|
-
//
|
|
3142
|
-
|
|
3111
|
+
// <zeebe:agentDefinition agentType="external" />
|
|
3112
|
+
//
|
|
3113
|
+
// inside its `extensionElements` (nano-workforce enforces it via
|
|
3114
|
+
// `agent-marker.test.ts`; registered in its `app/contracts.ts`). This replaces the
|
|
3115
|
+
// former dual `--auto` detection — the `@nanobpm/agentic` linked-prompt `agentic`
|
|
3116
|
+
// flag OR the pre-#203 `io.nanobpm.agentTask` `<zeebe:header>` fallback — with the
|
|
3117
|
+
// single marker every external agent task already declares. Behaviour change: a
|
|
3118
|
+
// prompt-bearing task WITHOUT the external marker is no longer auto-discovered
|
|
3119
|
+
// (safe for nano-workforce, where every `senior:*` task carries the marker).
|
|
3120
|
+
//
|
|
3121
|
+
// Preferred long-term derivation is a package-supplied `external` flag on each
|
|
3122
|
+
// `scanTaskDefinitions` leaf ("derivation over duplication"); until the package
|
|
3123
|
+
// exposes it, this self-contained supplement scan answers "is this leaf an
|
|
3124
|
+
// external agent task?" and `scanAgentTaskLeaves` prefers `leaf.external` when
|
|
3125
|
+
// present, falling back to it. The authoritative task-type / process derivation
|
|
3126
|
+
// still comes from `scanTaskDefinitions`.
|
|
3127
|
+
//
|
|
3128
|
+
// True when `body` (a service task's inner XML) declares a
|
|
3129
|
+
// `<zeebe:agentDefinition agentType="external">` element (attribute order and
|
|
3130
|
+
// quote style tolerated). Compiled once — called per matched `<serviceTask>`
|
|
3131
|
+
// during `--auto` scans, so recompiling per call would allocate needlessly.
|
|
3132
|
+
// CASE-SENSITIVE by design: XML attribute values are case-sensitive and the
|
|
3133
|
+
// convention specifies the literal `external`, so `agentType="External"`/`"EXTERNAL"`
|
|
3134
|
+
// is NOT the canonical marker and must not auto-enrol a non-conforming task.
|
|
3135
|
+
// Boundaries are anchored on XML whitespace / tag termination, NOT `\b`
|
|
3136
|
+
// (a word boundary): `agentDefinition\b` would also match a foreign element
|
|
3137
|
+
// like `<zeebe:agentDefinition-extra …>`. Require `[\s/>]` after the element
|
|
3138
|
+
// name so only the canonical marker enrols a task. Attribute matching is
|
|
3139
|
+
// QUOTE-AWARE (`parseXmlAttrs`, not a raw substring scan): a naive
|
|
3140
|
+
// `\sagentType\s*=\s*"external"` would also fire on `agentType='external'`
|
|
3141
|
+
// nested inside ANOTHER attribute's quoted value (e.g.
|
|
3142
|
+
// `description="text agentType='external'"`), auto-enrolling a non-conforming
|
|
3143
|
+
// task; parsing the element's attributes left-to-right and reading the real
|
|
3144
|
+
// unqualified `agentType` avoids that (and naturally rejects a prefixed
|
|
3145
|
+
// `other:agentType`). Compiled once — called per matched `<serviceTask>` during
|
|
3146
|
+
// `--auto` scans, so recompiling per call would allocate needlessly.
|
|
3147
|
+
const AGENT_DEFINITION_RE = /<(?:\w+:)?agentDefinition(?=[\s/>])([^>]*)>/g;
|
|
3148
|
+
function serviceTaskIsExternalAgent(body) {
|
|
3149
|
+
const src = String(body || '');
|
|
3150
|
+
AGENT_DEFINITION_RE.lastIndex = 0;
|
|
3151
|
+
let m;
|
|
3152
|
+
while ((m = AGENT_DEFINITION_RE.exec(src)) !== null) {
|
|
3153
|
+
if (parseXmlAttrs(m[1]).agentType === 'external') return true;
|
|
3154
|
+
}
|
|
3155
|
+
return false;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
// Opt-out namespace (issue #235): an external agent task authored with
|
|
3159
|
+
//
|
|
3160
|
+
// <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />
|
|
3161
|
+
//
|
|
3162
|
+
// is EXCLUDED from the `--auto` set — served only by explicit subscription
|
|
3163
|
+
// (`--job-type`/profile, unioned unchanged with `autoTypes`). Only the exact
|
|
3164
|
+
// `value="false"` opts out (fail-safe: any other value, or absence, auto-subscribes).
|
|
3165
|
+
const AGENT_TASK_AUTO_SUBSCRIBE_PROP = `${AGENT_TASK_NS}.autoSubscribe`;
|
|
3166
|
+
|
|
3167
|
+
// Parse a raw element attribute string (`name="…" other='…'`) into a
|
|
3168
|
+
// `{ name: value }` map, walking LEFT-TO-RIGHT and consuming each COMPLETE quoted
|
|
3169
|
+
// value so text INSIDE one attribute's value can never be mistaken for a separate
|
|
3170
|
+
// attribute. This is the quote-aware core both the external-marker and the
|
|
3171
|
+
// auto-subscribe-opt-out scans read through: a plain "search the whole string for
|
|
3172
|
+
// `key=…`" would match e.g. `value='false'` embedded in another attribute's
|
|
3173
|
+
// quoted value and misclassify the element. First occurrence wins (XML attribute
|
|
3174
|
+
// names are unique). Attribute NAMES may be namespace-prefixed and carry XML name
|
|
3175
|
+
// chars (`:`/`-`/`.`); values hold any non-quote text. Case-sensitive: XML
|
|
3176
|
+
// attribute names are case-sensitive and the conventions here are literal.
|
|
3177
|
+
function parseXmlAttrs(attrs) {
|
|
3178
|
+
const out = Object.create(null);
|
|
3179
|
+
const re = /([:\w.\-]+)\s*=\s*(["'])([\s\S]*?)\2/g;
|
|
3180
|
+
let m;
|
|
3181
|
+
while ((m = re.exec(String(attrs || ''))) !== null) {
|
|
3182
|
+
if (!(m[1] in out)) out[m[1]] = m[3];
|
|
3183
|
+
}
|
|
3184
|
+
return out;
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
// Read a named attribute's value from a raw element's attribute string
|
|
3188
|
+
// (`name="…"`/`name='…'`), quote-style and order tolerant; undefined when absent.
|
|
3189
|
+
// Quote-aware (via `parseXmlAttrs`): only a REAL attribute matches, so neither a
|
|
3190
|
+
// `\b`-style boundary trick (`other-name`) NOR text nested inside another
|
|
3191
|
+
// attribute's quoted value can masquerade as the canonical `name`/`value`.
|
|
3192
|
+
// Case-sensitive: XML attribute names are case-sensitive and the convention is literal.
|
|
3193
|
+
function readXmlAttr(attrs, key) {
|
|
3194
|
+
return parseXmlAttrs(attrs)[key];
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
// True when `body` (a service task's inner XML) declares a
|
|
3198
|
+
// `<zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false">` —
|
|
3199
|
+
// attribute order tolerated (name/value read independently). Fail-safe: only the
|
|
3200
|
+
// literal `false` opts out.
|
|
3201
|
+
function serviceTaskOptsOutOfAutoSubscribe(body) {
|
|
3202
|
+
const src = String(body || '');
|
|
3203
|
+
if (!src.includes(AGENT_TASK_AUTO_SUBSCRIBE_PROP)) return false;
|
|
3204
|
+
// `property(?=[\s/>])` (not `property\b`): a `\b` boundary would also match a
|
|
3205
|
+
// foreign element named `<zeebe:property-extra …>`, which — if it carried the
|
|
3206
|
+
// same `name`/`value` attributes — could wrongly opt a task out. Require XML
|
|
3207
|
+
// whitespace or tag termination right after the exact element name.
|
|
3208
|
+
// CASE-SENSITIVE (no `i` flag): XML element names are case-sensitive and the
|
|
3209
|
+
// convention specifies the literal `property`, so a non-canonical `<zeebe:Property …>`
|
|
3210
|
+
// is NOT the opt-out element and must not remove a task from `--auto`.
|
|
3211
|
+
const propRe = /<(?:\w+:)?property(?=[\s/>])([^>]*?)\/?>/g;
|
|
3212
|
+
let m;
|
|
3213
|
+
while ((m = propRe.exec(src)) !== null) {
|
|
3214
|
+
if (readXmlAttr(m[1], 'name') !== AGENT_TASK_AUTO_SUBSCRIBE_PROP) continue;
|
|
3215
|
+
if (readXmlAttr(m[1], 'value') === 'false') return true;
|
|
3216
|
+
}
|
|
3217
|
+
return false;
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
// Strip XML comments (`<!-- … -->`) and CDATA sections (`<![CDATA[ … ]]>`) from a
|
|
3221
|
+
// fragment so marker/property detection never fires on INERT text: a commented-out
|
|
3222
|
+
// or CDATA-wrapped `agentDefinition`/`autoSubscribe` is not a live declaration and
|
|
3223
|
+
// must not auto-enrol (or silently suppress) a task.
|
|
3224
|
+
function stripXmlCommentsAndCdata(s) {
|
|
3225
|
+
return String(s || '')
|
|
3226
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
3227
|
+
.replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '');
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
// Walk `xml` collecting the service-task element ids whose inner body satisfies
|
|
3231
|
+
// `predicate(body)`, correlating back to `scanTaskDefinitions` leaves by
|
|
3232
|
+
// `elementId`. Shared by the external-marker and auto-subscribe-opt-out scans.
|
|
3233
|
+
function serviceTaskElementIds(xml, guardSubstring, predicate) {
|
|
3143
3234
|
const ids = new Set();
|
|
3144
|
-
const
|
|
3145
|
-
// Cheap guard:
|
|
3146
|
-
//
|
|
3147
|
-
//
|
|
3148
|
-
//
|
|
3149
|
-
|
|
3150
|
-
|
|
3235
|
+
const raw = String(xml || '');
|
|
3236
|
+
// Cheap guard on the RAW document: skip the full `<serviceTask>` walk when the
|
|
3237
|
+
// document cannot possibly match, so `--auto` enrolment loops don't parse every
|
|
3238
|
+
// deployed definition needlessly. (A marker that appears ONLY inside a comment/
|
|
3239
|
+
// CDATA still passes this guard, but the strip below then correctly drops it.)
|
|
3240
|
+
if (guardSubstring && !raw.includes(guardSubstring)) return ids;
|
|
3241
|
+
// Strip comments/CDATA from the WHOLE document BEFORE matching serviceTasks: a
|
|
3242
|
+
// marker/property inside a comment or CDATA is inert and must not drive enrolment
|
|
3243
|
+
// or opt-out, AND an inert `</serviceTask>` inside a comment/CDATA would otherwise
|
|
3244
|
+
// truncate the non-greedy task-body capture at that fake close — hiding a real
|
|
3245
|
+
// marker/property later in the same task (silently omitting it from `--auto`).
|
|
3246
|
+
const src = stripXmlCommentsAndCdata(raw);
|
|
3151
3247
|
const taskRe =
|
|
3152
3248
|
/<(?:\w+:)?serviceTask\b[^>]*?\bid\s*=\s*(["'])(.*?)\1[^>]*?>([\s\S]*?)<\/(?:\w+:)?serviceTask>/g;
|
|
3153
3249
|
let m;
|
|
3154
3250
|
while ((m = taskRe.exec(src)) !== null) {
|
|
3155
|
-
if (
|
|
3251
|
+
if (predicate(m[3])) ids.add(m[2]);
|
|
3156
3252
|
}
|
|
3157
3253
|
return ids;
|
|
3158
3254
|
}
|
|
3159
3255
|
|
|
3160
|
-
//
|
|
3161
|
-
//
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
//
|
|
3167
|
-
// `
|
|
3168
|
-
|
|
3169
|
-
|
|
3256
|
+
// The set of service-task element ids in `xml` carrying the external-agent
|
|
3257
|
+
// marker (`<zeebe:agentDefinition agentType="external">`).
|
|
3258
|
+
function externalAgentElementIds(xml) {
|
|
3259
|
+
return serviceTaskElementIds(xml, 'agentDefinition', serviceTaskIsExternalAgent);
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
// The set of service-task element ids in `xml` opted out of `--auto`
|
|
3263
|
+
// (`io.nanobpm.agentTask.autoSubscribe = "false"`).
|
|
3264
|
+
function autoSubscribeOptOutElementIds(xml) {
|
|
3265
|
+
return serviceTaskElementIds(xml, AGENT_TASK_AUTO_SUBSCRIBE_PROP, serviceTaskOptsOutOfAutoSubscribe);
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
// Scan one deployed BPMN document for its `--auto`-eligible *agent* task-definition
|
|
3269
|
+
// leaves: the subset of `@nanobpm/agentic` `demand.scanTaskDefinitions(xml)` leaves
|
|
3270
|
+
// that carry the single-convention external-agent marker (issue #235) — preferring
|
|
3271
|
+
// a package-supplied `leaf.external` flag, falling back to the local
|
|
3272
|
+
// `externalAgentElementIds` scan — MINUS any leaf that opts out via
|
|
3273
|
+
// `io.nanobpm.agentTask.autoSubscribe="false"`. Returns `{ taskType, process }`
|
|
3274
|
+
// leaves in first-occurrence order. The published `scanTaskDefinitions` is INJECTED
|
|
3275
|
+
// so this stays a pure, synchronous function; `readDeployedAgentJobTypes` supplies
|
|
3276
|
+
// the real one from the lazily-imported demand surface (`agentic.mjs`).
|
|
3170
3277
|
function scanAgentTaskLeaves(xml, scanTaskDefinitions) {
|
|
3171
3278
|
if (typeof scanTaskDefinitions !== 'function') {
|
|
3172
3279
|
throw new TypeError(
|
|
@@ -3175,9 +3282,17 @@ function scanAgentTaskLeaves(xml, scanTaskDefinitions) {
|
|
|
3175
3282
|
);
|
|
3176
3283
|
}
|
|
3177
3284
|
const src = String(xml || '');
|
|
3178
|
-
const
|
|
3285
|
+
const externalIds = externalAgentElementIds(src);
|
|
3286
|
+
const optedOutIds = autoSubscribeOptOutElementIds(src);
|
|
3179
3287
|
return scanTaskDefinitions(src)
|
|
3180
|
-
|
|
3288
|
+
// A package-supplied boolean `leaf.external` is AUTHORITATIVE: `false` is not
|
|
3289
|
+
// the same as absent — it explicitly classifies the leaf as non-external, so
|
|
3290
|
+
// it must exclude the leaf even when the local marker scan sees the marker.
|
|
3291
|
+
// Fall back to the local `externalAgentElementIds` scan ONLY when the package
|
|
3292
|
+
// does not supply a boolean flag.
|
|
3293
|
+
.filter((leaf) =>
|
|
3294
|
+
typeof leaf.external === 'boolean' ? leaf.external : externalIds.has(leaf.elementId))
|
|
3295
|
+
.filter((leaf) => !optedOutIds.has(leaf.elementId))
|
|
3181
3296
|
.map((leaf) => ({ taskType: leaf.taskType, process: leaf.process }));
|
|
3182
3297
|
}
|
|
3183
3298
|
|
|
@@ -3329,6 +3444,7 @@ async function createAgenticEndpoint(opts) {
|
|
|
3329
3444
|
// @param {string} [opts.worker] worker id stamped on activations
|
|
3330
3445
|
// @param {Array<{id: string, types?: Iterable<string>, capacity?: number}>} [opts.workers] registry seed
|
|
3331
3446
|
// @param {string} [opts.autoWorkerId] worker whose types the reconcile loop rewrites
|
|
3447
|
+
// @param {ReadonlyArray<string>} [opts.autoExtraTypes] explicit `--job-type` extras unioned into every reconcile write (survive the auto rewrite). MUST be an array (the supervisor gates the union on `.length`); a bare `Iterable`/`Set` would be silently dropped.
|
|
3332
3448
|
// @param {import('./supervisor.dist.js').AgenticEndpoint} [opts.agenticEndpoint] the ownership wire
|
|
3333
3449
|
// @param {object} [opts.agenticConfig] reconnect backoff config
|
|
3334
3450
|
// @param {string} [opts.scope] reconcile process-id scope narrowing
|
|
@@ -3363,6 +3479,7 @@ async function createSupervisorDeps(opts = {}) {
|
|
|
3363
3479
|
worker,
|
|
3364
3480
|
workers = [],
|
|
3365
3481
|
autoWorkerId,
|
|
3482
|
+
autoExtraTypes,
|
|
3366
3483
|
agenticEndpoint,
|
|
3367
3484
|
agenticConfig,
|
|
3368
3485
|
scope = '',
|
|
@@ -3437,6 +3554,7 @@ async function createSupervisorDeps(opts = {}) {
|
|
|
3437
3554
|
scan,
|
|
3438
3555
|
logger,
|
|
3439
3556
|
autoWorkerId,
|
|
3557
|
+
autoExtraTypes,
|
|
3440
3558
|
agenticEndpoint,
|
|
3441
3559
|
agenticConfig,
|
|
3442
3560
|
config: scope ? { ...config, scope } : config,
|
|
@@ -4157,17 +4275,35 @@ function githubCloneToken({ provider, authRef, secretResolver, ghAuthToken = ghA
|
|
|
4157
4275
|
// Clone repo into <runDir>/workspace and check out / create the working branch.
|
|
4158
4276
|
// Returns { workspaceDir, gitEnv, startSha, workingBranch, remote }. Throws a
|
|
4159
4277
|
// ProvisionError (token-redacted) on any git failure so the caller can shed.
|
|
4160
|
-
function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, logger = null, corr = '', _runGit = null }) {
|
|
4278
|
+
function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, logger = null, corr = '', abortSignal = null, _runGit = null }) {
|
|
4161
4279
|
// Test-only seam mirroring finalizeGit: route every git call through `runGitFn` so a
|
|
4162
4280
|
// deterministic test can observe the per-call timeouts drawn from the shared
|
|
4163
4281
|
// provisioning budget below. Defaults to the module `runGit` (production unchanged).
|
|
4164
4282
|
const runGitFn = _runGit || runGit;
|
|
4283
|
+
// #222: the git ops below are blocking spawnSync calls that cannot observe an
|
|
4284
|
+
// AbortSignal mid-call, so provisioning is made signal-aware by rechecking
|
|
4285
|
+
// BETWEEN operations: if a lock-loss race won, bail before the NEXT side effect
|
|
4286
|
+
// (the sha fetch/checkout, base fetch, or config writes) instead of pressing on.
|
|
4287
|
+
// The throw is a ProvisionError so the callsite's existing catch reaps the clone;
|
|
4288
|
+
// that catch rechecks the same signal and returns WITHOUT settling.
|
|
4289
|
+
const throwIfAborted = (stage) => {
|
|
4290
|
+
if (abortSignal && abortSignal.aborted === true) {
|
|
4291
|
+
throw new ProvisionError(`provisioning aborted during ${stage} — lease loss/force-stop won the race`);
|
|
4292
|
+
}
|
|
4293
|
+
};
|
|
4165
4294
|
const repo = envelope.repository;
|
|
4166
4295
|
if (!repo || !repo.url) throw new ProvisionError('repository.url is required to provision a workspace');
|
|
4167
4296
|
// #229: correlation suffix so git provisioning lines can be joined to the job /
|
|
4168
4297
|
// AgentInstance / relay channels (git logs carried no elementInstanceKey before).
|
|
4169
4298
|
const cs = corr ? ` [${corr}]` : '';
|
|
4170
4299
|
const workspaceDir = join(runDir, 'workspace');
|
|
4300
|
+
// #222: honor an ALREADY-aborted signal at the seam ENTRY, before the first repo
|
|
4301
|
+
// side effects (the askpass helper write and the clone). Without this, a signal
|
|
4302
|
+
// that flipped before provisionRepo was even called still wrote the askpass helper
|
|
4303
|
+
// and ran `git clone` before the first `throwIfAborted('post-clone')` fired — the
|
|
4304
|
+
// caller's gate has a race window, so the exported provisioning seam must refuse an
|
|
4305
|
+
// already-lost lease without starting any repository side effect.
|
|
4306
|
+
throwIfAborted('entry');
|
|
4171
4307
|
const askpass = writeAskpass(runDir, token);
|
|
4172
4308
|
const gitEnv = {
|
|
4173
4309
|
...process.env,
|
|
@@ -4252,10 +4388,22 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4252
4388
|
throw new ProvisionError(`git clone failed: ${gitErrorDetail(clone, token)}`);
|
|
4253
4389
|
}
|
|
4254
4390
|
|
|
4391
|
+
// #222: the clone (the dominant blocking op and the first repo side effect) is
|
|
4392
|
+
// done. Recheck the abort signal before the remaining side effects/network ops
|
|
4393
|
+
// (sha fetch+checkout, base fetch, config writes) so a lock-loss race that won
|
|
4394
|
+
// during the clone stops here rather than compounding the wasted work.
|
|
4395
|
+
throwIfAborted('post-clone');
|
|
4396
|
+
|
|
4255
4397
|
if (isSha) {
|
|
4256
4398
|
// The SHA may not be present under a shallow clone of the branch — fetch it
|
|
4257
4399
|
// explicitly (best effort), then check it out (detached HEAD).
|
|
4258
4400
|
const fetch = runGitFn([...credArgs(), 'fetch', '--no-tags', 'origin', commitSha], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
|
|
4401
|
+
// #222: the fetch above is its own blocking network op — recheck the abort signal
|
|
4402
|
+
// BEFORE the checkout mutates the working tree, so a lock-loss race that won while
|
|
4403
|
+
// `git fetch origin <sha>` was blocking stops here (cleanup/no-settle path) rather
|
|
4404
|
+
// than running `git checkout --detach` and continuing to mutate the throwaway repo
|
|
4405
|
+
// after cancellation.
|
|
4406
|
+
throwIfAborted('post-sha-fetch');
|
|
4259
4407
|
const co = runGitFn(['checkout', '--detach', commitSha], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
|
|
4260
4408
|
if (co.status !== 0) {
|
|
4261
4409
|
// Combine the fetch + checkout output (the real reason often lives in the
|
|
@@ -4266,6 +4414,9 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4266
4414
|
}
|
|
4267
4415
|
}
|
|
4268
4416
|
|
|
4417
|
+
// #222: recheck before the base-fetch network probes (another blocking op set).
|
|
4418
|
+
throwIfAborted('pre-base-fetch');
|
|
4419
|
+
|
|
4269
4420
|
// Optional base fetch: with a single-branch/shallow clone the head has no base
|
|
4270
4421
|
// and no merge-base, so a naive `git diff <base>` fails. When a base branch or
|
|
4271
4422
|
// sha is supplied, fetch it (respecting depth/filter) into a remote-tracking
|
|
@@ -4309,6 +4460,13 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4309
4460
|
}
|
|
4310
4461
|
}
|
|
4311
4462
|
}
|
|
4463
|
+
// #222: the pre-base-fetch snapshot probe above can itself BLOCK on the network
|
|
4464
|
+
// (the `ls-remote --heads` fallback when there is no local `origin/<base>`), so
|
|
4465
|
+
// `pre-base-fetch` (before the probe) is too early to cover an abort that wins
|
|
4466
|
+
// DURING it. Recheck here — after the snapshot probe, before the OPTIONAL base
|
|
4467
|
+
// fetch below — so a lock-loss race that landed while the snapshot ls-remote was
|
|
4468
|
+
// blocking stops before the base fetch mutates the throwaway repo (thread 4389).
|
|
4469
|
+
throwIfAborted('pre-base-fetch-op');
|
|
4312
4470
|
let base = '';
|
|
4313
4471
|
let baseFetchError;
|
|
4314
4472
|
// `baseRef` (branch/tag) and `baseSha` (raw commit) are mutually exclusive — a
|
|
@@ -4349,6 +4507,12 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4349
4507
|
}
|
|
4350
4508
|
}
|
|
4351
4509
|
|
|
4510
|
+
// #222: the base-fetch group above is the last blocking NETWORK op set. Recheck the
|
|
4511
|
+
// abort signal BEFORE the committer-config writes (which mutate .git/config) so a
|
|
4512
|
+
// lock-loss race that won while the base fetch / snapshot probes were blocking stops
|
|
4513
|
+
// here rather than mutating the throwaway repo's config after cancellation.
|
|
4514
|
+
throwIfAborted('post-base-fetch');
|
|
4515
|
+
|
|
4352
4516
|
// Give the harness a committer identity in case it commits (many do). Prefer
|
|
4353
4517
|
// the operator's real identity (git global / gh user) over the `nano-agent`
|
|
4354
4518
|
// fallback so autonomous commits are authored by the human running the fleet —
|
|
@@ -4356,8 +4520,17 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4356
4520
|
// is instead recorded as a PR comment (postAgentAttribution). Set via repo-
|
|
4357
4521
|
// level config, which overrides global, so the identity is deterministic.
|
|
4358
4522
|
const committer = resolveCommitterIdentity();
|
|
4523
|
+
// #222: `resolveCommitterIdentity()` runs blocking git/gh identity probes and the
|
|
4524
|
+
// two `git config` writes below MUTATE .git/config, so `post-base-fetch` (before
|
|
4525
|
+
// the identity resolution) is not the last gate. Recheck after identity resolution
|
|
4526
|
+
// and around each config write so an abort that wins during any of them stops
|
|
4527
|
+
// before starting the next blocking op rather than mutating the throwaway repo's
|
|
4528
|
+
// config after cancellation (suppressed advisory 4389).
|
|
4529
|
+
throwIfAborted('post-committer-identity');
|
|
4359
4530
|
runGitFn(['config', 'user.name', committer.name], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
|
|
4531
|
+
throwIfAborted('post-committer-name');
|
|
4360
4532
|
runGitFn(['config', 'user.email', committer.email], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
|
|
4533
|
+
throwIfAborted('post-committer-config');
|
|
4361
4534
|
// Config alone is not enough: git honours GIT_AUTHOR_*/GIT_COMMITTER_* OVER
|
|
4362
4535
|
// user.name/user.email config, so a placeholder GIT_AUTHOR_EMAIL inherited from
|
|
4363
4536
|
// the launch environment (e.g. `trial-merge@nano.local`) would still be stamped
|
|
@@ -4586,6 +4759,13 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4586
4759
|
// default could NOT be verified and no base was configured, fail closed (treat every
|
|
4587
4760
|
// such create as base-like) so an unverified default can never slip the guard (4444).
|
|
4588
4761
|
const createNamesBase = (name) => name === effectiveBase || (!!remoteDefaultBranch && name === remoteDefaultBranch) || defaultUnverified;
|
|
4762
|
+
// #222: the branch-state probes above (symref/HEAD reads, refBaseBranch and
|
|
4763
|
+
// remoteDefaultBranch resolution) are blocking git ops too. Recheck the abort signal
|
|
4764
|
+
// one last time BEFORE the branch-checkout decisions below, which run `git checkout
|
|
4765
|
+
// -B` — the working-tree mutation this issue guards. A lock-loss race that won while
|
|
4766
|
+
// those probes were blocking stops here rather than cutting/checking out a branch on
|
|
4767
|
+
// the throwaway repo after cancellation.
|
|
4768
|
+
throwIfAborted('pre-branch-checkout');
|
|
4589
4769
|
// Defense against silent work-loss (issue #231): committing on the base branch
|
|
4590
4770
|
// with intent to push is ALWAYS wrong for the PR flow — a push to the shared
|
|
4591
4771
|
// base races it and a non-ff reject strands the commits in this throwaway
|
|
@@ -4644,6 +4824,12 @@ function provisionRepo({ envelope, token, runDir, runId, timeoutMs = 120_000, lo
|
|
|
4644
4824
|
else log.debug?.(`provisionRepo${cs}: no branch.create; push disabled → working read-only on '${checkedOut}'`);
|
|
4645
4825
|
}
|
|
4646
4826
|
}
|
|
4827
|
+
// #222: the branch-selection block above runs `git checkout -B` (the working-tree
|
|
4828
|
+
// mutation) plus its fallback-branch cuts. Recheck one last time AFTER those ops and
|
|
4829
|
+
// BEFORE the HEAD/base-tip probes below, so a lock-loss race that won DURING the
|
|
4830
|
+
// checkout stops at this boundary (cleanup + no-settle) rather than continuing
|
|
4831
|
+
// through the remaining synchronous provisioning until the later relay-open gate.
|
|
4832
|
+
throwIfAborted('post-branch-checkout');
|
|
4647
4833
|
const sha = runGitFn(['rev-parse', 'HEAD'], { cwd: workspaceDir, env: gitEnv, timeoutMs: provTimeoutMs() });
|
|
4648
4834
|
// Capture the base ref's SHA AT CLONE TIME (issue #229/#231 observability): the
|
|
4649
4835
|
// harness runs arbitrary code between here and finalizeGit and can itself advance
|
|
@@ -8336,12 +8522,53 @@ function localNetworkTccHint() {
|
|
|
8336
8522
|
].join('\n');
|
|
8337
8523
|
}
|
|
8338
8524
|
|
|
8525
|
+
/**
|
|
8526
|
+
* #222 — setup-phase abort gate. #221 makes the dispatch heartbeat `raceFirst`
|
|
8527
|
+
* interrupt the agent on a definitive lease loss, and `runAgentJob` honours that
|
|
8528
|
+
* by wiring the forwarded `AbortSignal` to killTree the harness. But `workAgent`
|
|
8529
|
+
* does real work BEFORE it reaches `runAgentJob` — prompt/AgentInstance setup,
|
|
8530
|
+
* repository provisioning, and `relaySessionFor` (which emits `lifecycle/open`,
|
|
8531
|
+
* the first transcript event). None of that setup path observes the signal, so if
|
|
8532
|
+
* a lock-loss race wins DURING setup the abandoned run would still create the
|
|
8533
|
+
* transcript husk (an empty `lifecycle/open`-only record) and perform repo side
|
|
8534
|
+
* effects before an eventually-fenced settle. Call this at each pre-`runAgentJob`
|
|
8535
|
+
* stage boundary: when the signal is already aborted it logs once and returns
|
|
8536
|
+
* `true` so the caller can skip that stage's side effect and return WITHOUT
|
|
8537
|
+
* settling (the lease is lost / the job is being yielded for retry). A missing or
|
|
8538
|
+
* never-aborted signal is a no-op (returns `false`), leaving the normal and
|
|
8539
|
+
* graceful-drain paths unchanged.
|
|
8540
|
+
*
|
|
8541
|
+
* @param {AbortSignal|null|undefined} abortSignal the run's interruption signal
|
|
8542
|
+
* @param {{ jobType?: string, jobKey?: string|number, stage?: string, logger?: { warn?: (msg: string) => void } }} [ctx]
|
|
8543
|
+
* @returns {boolean} true iff the run was aborted during setup and must stop
|
|
8544
|
+
*/
|
|
8545
|
+
function checkSetupAbort(abortSignal, { jobType, jobKey, stage, logger } = {}) {
|
|
8546
|
+
if (!abortSignal || abortSignal.aborted !== true) return false;
|
|
8547
|
+
// The invariant that holds at EVERY stage is the same: we stop and DO NOT settle,
|
|
8548
|
+
// so the job is yielded for retry. What has already happened by this point is NOT
|
|
8549
|
+
// the same across stages, though — the earlier "no repo side effects / no transcript
|
|
8550
|
+
// husk" wording was inaccurate at `agent-instance` (an instance may have minted, then
|
|
8551
|
+
// discarded here) and especially at `repo-provisioning`/`relay-open` (the throwaway
|
|
8552
|
+
// clone + config + branch setup may already exist). Describe only what is TRUE for
|
|
8553
|
+
// this stage so operators investigating a cleaned-up but partially-provisioned run
|
|
8554
|
+
// are not misled (issue #222).
|
|
8555
|
+
const sideEffectNote = stage === 'prompt'
|
|
8556
|
+
? 'stopping before any side effect (no transcript, no repo work, no settle)'
|
|
8557
|
+
: stage === 'agent-instance'
|
|
8558
|
+
? 'any AgentInstance minted this run is discarded (no COMPLETED update); stopping without a settle'
|
|
8559
|
+
: stage === 'repo-provisioning' || stage === 'relay-open'
|
|
8560
|
+
? 'any partially-provisioned throwaway workspace is removed best-effort on the way out (a failed removal is left for the age-gated reaper; the entry gate may have created none yet) and no transcript is completed; stopping without a settle'
|
|
8561
|
+
: 'stopping without a settle';
|
|
8562
|
+
logger?.warn?.(`[${jobType}] job ${jobKey} aborted during setup (${stage}) — lease loss/force-stop won the race; ${sideEffectNote} (the job is being yielded for retry).`);
|
|
8563
|
+
return true;
|
|
8564
|
+
}
|
|
8565
|
+
|
|
8339
8566
|
/**
|
|
8340
8567
|
* work — turn a hire profile into live Nano job workers (one per job-type in
|
|
8341
8568
|
* the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
|
|
8342
8569
|
* Uses the c8ctl-provided SDK client (globalThis.c8ctl.createClient()).
|
|
8343
8570
|
*/
|
|
8344
|
-
async function workAgent(req, flags) {
|
|
8571
|
+
async function workAgent(req, flags, ctx) {
|
|
8345
8572
|
const logger = getLogger();
|
|
8346
8573
|
// The hire to run always comes from the positional profile. `--name` no longer
|
|
8347
8574
|
// selects the hire (that was a footgun: `work reviewer --name coder` silently
|
|
@@ -8591,9 +8818,11 @@ async function workAgent(req, flags) {
|
|
|
8591
8818
|
// no capability, no app enrol endpoint, no channel connection. It is the
|
|
8592
8819
|
// mutually-exclusive counterpart to capability-resolved SERVE: in `--auto`
|
|
8593
8820
|
// the rank×capability matrix is bypassed entirely (any deployed agent job is
|
|
8594
|
-
// served, gated only by the leaf's canonical
|
|
8595
|
-
//
|
|
8596
|
-
//
|
|
8821
|
+
// served, gated only by the leaf's canonical external-agent marker —
|
|
8822
|
+
// `<zeebe:agentDefinition agentType="external">` (issue #235), preferring a
|
|
8823
|
+
// package-supplied boolean `leaf.external` flag and falling back to a local
|
|
8824
|
+
// scan, minus any leaf that opts out via
|
|
8825
|
+
// `io.nanobpm.agentTask.autoSubscribe="false"`), and the
|
|
8597
8826
|
// desired set is reconciled by polling the engine rather than watching the
|
|
8598
8827
|
// profile. `--auto-scope <process-id|prefix>` narrows the blast radius to one
|
|
8599
8828
|
// app/network; without it, every agent job type on the engine is served.
|
|
@@ -8621,7 +8850,14 @@ async function workAgent(req, flags) {
|
|
|
8621
8850
|
// bad DNS answer is corrected, without a supervisor restart. Set before the
|
|
8622
8851
|
// client is created so every outbound inherits it.
|
|
8623
8852
|
preferIpv4Resolution();
|
|
8624
|
-
|
|
8853
|
+
// Honour c8ctl's global `--profile <name>` for this invocation: the handler
|
|
8854
|
+
// ctx carries `profile` (the `--profile` override, else the active session
|
|
8855
|
+
// profile). Passing it to createClient(profile) connects the worker to the
|
|
8856
|
+
// profile named on the command line, not just the active session — closing the
|
|
8857
|
+
// silently-ignored-`--profile` gap (jwulf/c8ctl-plugin-nano#189). ctx-less
|
|
8858
|
+
// callers (undefined) fall back to createClient(undefined), which resolves the
|
|
8859
|
+
// active profile itself — identical to the old no-arg behaviour.
|
|
8860
|
+
const camunda = globalThis.c8ctl.createClient(resolveConnectionProfile(ctx));
|
|
8625
8861
|
|
|
8626
8862
|
// Broker REST endpoint for live linked-resource prompts (issue #63) and the
|
|
8627
8863
|
// C8 REST source for `--auto`'s engine-read enrolment. Derived from the SAME
|
|
@@ -8970,6 +9206,15 @@ async function workAgent(req, flags) {
|
|
|
8970
9206
|
try {
|
|
8971
9207
|
logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
|
|
8972
9208
|
|
|
9209
|
+
// #222: setup-abort gate (stage: prompt). If a lock-loss race already won
|
|
9210
|
+
// before setup begins, stop now — before the prompt/AgentInstance fetch,
|
|
9211
|
+
// repo provisioning, or the first transcript event. Return WITHOUT settling
|
|
9212
|
+
// (the lease is lost / the job is being yielded), so no husk is created.
|
|
9213
|
+
// This gate sits AHEAD of every pre-setup failure/settlement branch (the
|
|
9214
|
+
// disk-budget shed below included): an already-aborted run must return
|
|
9215
|
+
// without settling rather than race the force-stop yield with a fail-settle.
|
|
9216
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'prompt', logger })) return;
|
|
9217
|
+
|
|
8973
9218
|
// Disk-budget admission shed: if the engine data root is below the free
|
|
8974
9219
|
// floor, don't start a container — fail (retryable) so work sheds until
|
|
8975
9220
|
// the reaper/host frees space.
|
|
@@ -8978,6 +9223,12 @@ async function workAgent(req, flags) {
|
|
|
8978
9223
|
if (!budget.ok) {
|
|
8979
9224
|
const freeMb = budget.free != null ? Math.round(budget.free / 1_048_576) : '?';
|
|
8980
9225
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
9226
|
+
// #222: diskBudgetOk() runs synchronous Docker probes for container jobs
|
|
9227
|
+
// (including a 10s spawnSync), so the abort can flip WHILE we were checking
|
|
9228
|
+
// disk. Recheck before the low-disk fail-settle: an already-lost lease must
|
|
9229
|
+
// return WITHOUT settling (yield the job) rather than be clobbered into a
|
|
9230
|
+
// retryable provisioning failure that races the force-stop yield.
|
|
9231
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'prompt', logger })) return;
|
|
8981
9232
|
logger.warn(`[${jobType}] job ${job.jobKey} shed — low disk (${freeMb}MB free); retries left ${retries}`);
|
|
8982
9233
|
return settleJob.fail({ errorMessage: `disk budget exceeded (only ${freeMb}MB free)`, retries, retryBackOff: 30_000 });
|
|
8983
9234
|
}
|
|
@@ -8990,6 +9241,7 @@ async function workAgent(req, flags) {
|
|
|
8990
9241
|
// (retryable) rather than run an agent with an empty prompt.
|
|
8991
9242
|
let promptResourceKey = null;
|
|
8992
9243
|
let basePromptOverride;
|
|
9244
|
+
let promptFetchError = null;
|
|
8993
9245
|
try {
|
|
8994
9246
|
// Fetch the prompt from the broker the SDK client is connected to,
|
|
8995
9247
|
// deriving base URL + auth from that client (not restConfig, whose base
|
|
@@ -9008,6 +9260,20 @@ async function workAgent(req, flags) {
|
|
|
9008
9260
|
logger.info(`[${jobType}] job ${job.jobKey} base prompt from linked resource key ${promptResourceKey} (linkName=${linked.linkName}, ${Buffer.byteLength(String(basePromptOverride), 'utf8')} bytes)`);
|
|
9009
9261
|
}
|
|
9010
9262
|
} catch (err) {
|
|
9263
|
+
// Capture — don't settle here. The prompt fetch above is AWAITED, so a
|
|
9264
|
+
// lock-loss race can win DURING it (surfacing as either a rejection here
|
|
9265
|
+
// or a value that then reaches the missing-secret settlement below). Defer
|
|
9266
|
+
// the settlement past the post-await abort recheck so an aborted run
|
|
9267
|
+
// returns WITHOUT settling rather than racing the force-stop yield.
|
|
9268
|
+
promptFetchError = err;
|
|
9269
|
+
}
|
|
9270
|
+
|
|
9271
|
+
// #222: recheck after the awaited prompt fetch (stage: prompt) — before the
|
|
9272
|
+
// prompt-fetch failure settlement above OR the missing-secret settlement
|
|
9273
|
+
// below. An abort that landed during the await returns WITHOUT settling.
|
|
9274
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'prompt', logger })) return;
|
|
9275
|
+
if (promptFetchError) {
|
|
9276
|
+
const err = promptFetchError;
|
|
9011
9277
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
9012
9278
|
const msg = err instanceof ProvisionError ? err.message : `prompt resource fetch failed: ${err.message}`;
|
|
9013
9279
|
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
@@ -9051,6 +9317,15 @@ async function workAgent(req, flags) {
|
|
|
9051
9317
|
const runId = randomUUID();
|
|
9052
9318
|
if (isContainer) liveRunIds.add(runId);
|
|
9053
9319
|
|
|
9320
|
+
// #222: setup-abort gate (stage: agent-instance). The prompt fetch above
|
|
9321
|
+
// awaited the broker, so a lock-loss race may have won during it. Stop
|
|
9322
|
+
// before minting the durable AgentInstance (a side effect) and return
|
|
9323
|
+
// without settling; release the just-allocated container run id.
|
|
9324
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'agent-instance', logger })) {
|
|
9325
|
+
if (isContainer) liveRunIds.delete(runId);
|
|
9326
|
+
return;
|
|
9327
|
+
}
|
|
9328
|
+
|
|
9054
9329
|
// #194: durable engine-native AgentInstance producer. For an `external`
|
|
9055
9330
|
// agent job (one carrying the activation's lease token + elementInstanceKey)
|
|
9056
9331
|
// mint an AgentInstance now — lease-gated on THIS activation — seeding the
|
|
@@ -9064,6 +9339,21 @@ async function workAgent(req, flags) {
|
|
|
9064
9339
|
// end the instance is driven to COMPLETED (success only) — `job.complete`
|
|
9065
9340
|
// fires exactly as before regardless.
|
|
9066
9341
|
let agentInstanceProducer = null;
|
|
9342
|
+
// #222: shared teardown for EVERY setup-abort return branch after the
|
|
9343
|
+
// producer has been created. There is no delete verb for a minted instance,
|
|
9344
|
+
// so `discard()` the producer: it makes it permanently inert (dropping any
|
|
9345
|
+
// late/in-flight create so an abandoned run can't mint an orphaned instance
|
|
9346
|
+
// after we return) and drains already-queued appends — WITHOUT driving a
|
|
9347
|
+
// COMPLETED update (that would try to complete an instance for a run being
|
|
9348
|
+
// yielded for retry). Covers BOTH an active producer AND a retry-pending one
|
|
9349
|
+
// whose armed create could otherwise settle late. Every abort branch below
|
|
9350
|
+
// (agent-instance, repo-provisioning entry, the provisionRepo-catch abort,
|
|
9351
|
+
// and relay-open) routes through this so none leaves the producer un-discarded.
|
|
9352
|
+
const discardAgentInstanceProducer = async () => {
|
|
9353
|
+
if (agentInstanceProducer?.active || agentInstanceProducer?.retryPending) {
|
|
9354
|
+
try { await agentInstanceProducer.discard(); } catch { /* best effort */ }
|
|
9355
|
+
}
|
|
9356
|
+
};
|
|
9067
9357
|
const agentInstanceOff = String(process.env.NANO_AGENT_INSTANCE || '').trim().toLowerCase() === 'off';
|
|
9068
9358
|
// #229: correlation stamp for the decision-point + outer-catch logs below,
|
|
9069
9359
|
// so the AgentInstance producer lifecycle can be joined to the relay/git/job
|
|
@@ -9094,6 +9384,25 @@ async function workAgent(req, flags) {
|
|
|
9094
9384
|
logger.debug?.(`[${jobType}] AgentInstance producer skipped (${aiCorr}) — ${agentInstanceOff ? 'NANO_AGENT_INSTANCE=off' : 'not an external agent job (no lease token / elementInstanceKey)'}.`);
|
|
9095
9385
|
}
|
|
9096
9386
|
|
|
9387
|
+
// #222: recheck after the awaited activate() (stage: agent-instance). The
|
|
9388
|
+
// pre-activate gate above only guards ENTRY; `activate()` then awaits an
|
|
9389
|
+
// uncancellable createAgentInstance round-trip, so a lock-loss race can win
|
|
9390
|
+
// DURING it (and a pending create may still mint a durable instance). Recheck
|
|
9391
|
+
// BEFORE the later setup-failure settlements (the malformed-repository refusal
|
|
9392
|
+
// below and the repo-provisioning path) so an aborted run returns WITHOUT
|
|
9393
|
+
// settling. There is no delete verb for a minted instance, so `discard()` the
|
|
9394
|
+
// producer: it makes it permanently inert (dropping any late create so an
|
|
9395
|
+
// abandoned run can't mint an orphaned instance after we return) and drains
|
|
9396
|
+
// already-queued appends — WITHOUT driving a COMPLETED update (that would try
|
|
9397
|
+
// to mint/complete an instance for a run being yielded for retry). This covers
|
|
9398
|
+
// BOTH an active producer AND a retry-pending one whose in-flight/armed create
|
|
9399
|
+
// could otherwise settle late and mint after the abort path returned.
|
|
9400
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'agent-instance', logger })) {
|
|
9401
|
+
await discardAgentInstanceProducer();
|
|
9402
|
+
if (isContainer) liveRunIds.delete(runId);
|
|
9403
|
+
return;
|
|
9404
|
+
}
|
|
9405
|
+
|
|
9097
9406
|
// Fail-closed on a half-specified repository envelope (issue #129,
|
|
9098
9407
|
// hardening 2): a `repository` block that declares intent (any field set)
|
|
9099
9408
|
// but whose `url` is absent or not a usable clone target almost always
|
|
@@ -9122,6 +9431,18 @@ async function workAgent(req, flags) {
|
|
|
9122
9431
|
const hasRepo = !isContainer && !!envelope.repository?.url;
|
|
9123
9432
|
let runDir = null;
|
|
9124
9433
|
let provisioned = null;
|
|
9434
|
+
// #222: setup-abort gate (stage: repo-provisioning). The AgentInstance
|
|
9435
|
+
// activate above awaited the broker, so a lock-loss race may have won.
|
|
9436
|
+
// Stop BEFORE cloning/branching (the first repo side effects) and return
|
|
9437
|
+
// without settling; release the just-allocated container run id.
|
|
9438
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'repo-provisioning', logger })) {
|
|
9439
|
+
// #222: an active/retry-pending producer minted before this gate must be
|
|
9440
|
+
// made inert too — otherwise a late create resolves after we return and
|
|
9441
|
+
// mints an orphaned AgentInstance, while an active one is never finalized.
|
|
9442
|
+
await discardAgentInstanceProducer();
|
|
9443
|
+
if (isContainer) liveRunIds.delete(runId);
|
|
9444
|
+
return;
|
|
9445
|
+
}
|
|
9125
9446
|
// The broker activation lock is owned by the single-owner runtime's dispatch
|
|
9126
9447
|
// lifecycle (supervisor/src/dispatch.ts): it extends the winner to the
|
|
9127
9448
|
// recovery window BEFORE this runner starts and heartbeats it on a Schedule
|
|
@@ -9137,7 +9458,7 @@ async function workAgent(req, flags) {
|
|
|
9137
9458
|
mkdirSync(workerNsDir, { recursive: true });
|
|
9138
9459
|
runDir = mkdtempSync(join(workerNsDir, 'run-'));
|
|
9139
9460
|
liveRunDirs.add(runDir);
|
|
9140
|
-
provisioned = provisionRepo({ envelope, token: repoToken, runDir, runId, timeoutMs: cloneTimeoutMs, logger, corr: aiCorr });
|
|
9461
|
+
provisioned = provisionRepo({ envelope, token: repoToken, runDir, runId, timeoutMs: cloneTimeoutMs, logger, corr: aiCorr, abortSignal });
|
|
9141
9462
|
if (provisioned.baseFetchError) {
|
|
9142
9463
|
logger.warn(`[${jobType}] job ${job.jobKey} base fetch failed (${aiCorr}) — ${oneLineLog(provisioned.baseFetchError)}; base...head diffs may be unavailable`);
|
|
9143
9464
|
}
|
|
@@ -9164,6 +9485,13 @@ async function workAgent(req, flags) {
|
|
|
9164
9485
|
} catch (err) {
|
|
9165
9486
|
if (runDir) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(runDir); }
|
|
9166
9487
|
if (isContainer) liveRunIds.delete(runId);
|
|
9488
|
+
// #222: guard the abort/error exit BEFORE settling. `provisionRepo` runs
|
|
9489
|
+
// blocking spawnSync clone/fetch/checkout ops and only observes the signal
|
|
9490
|
+
// BETWEEN them, so a lock-loss race that won mid-clone surfaces here as a
|
|
9491
|
+
// throw. If the run was aborted, return WITHOUT settling (the run-dir is
|
|
9492
|
+
// already reaped above) rather than fail-settle and race the force-stop
|
|
9493
|
+
// yield. The throwaway clone is the only residue, and it is reaped.
|
|
9494
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'repo-provisioning', logger })) { await discardAgentInstanceProducer(); return; }
|
|
9167
9495
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
9168
9496
|
const msg = err instanceof ProvisionError ? err.message : `provisioning error: ${err.message}`;
|
|
9169
9497
|
// #229: include the correlation keys — a clone/checkout failure occurs
|
|
@@ -9221,6 +9549,24 @@ async function workAgent(req, flags) {
|
|
|
9221
9549
|
// cockpit steer-in fanned back to this job's PTY by the runtime's steer
|
|
9222
9550
|
// router. Only when the worker is enrolled (a live agentic plane); closed
|
|
9223
9551
|
// in the finally so its steer subscription never leaks across jobs.
|
|
9552
|
+
// #222: setup-abort gate (stage: relay-open). This is the last gate before
|
|
9553
|
+
// `relaySessionFor` emits `lifecycle/open` — the FIRST transcript event and
|
|
9554
|
+
// the source of the empty transcript husk this issue targets. If a lock-loss
|
|
9555
|
+
// race won during provisioning, stop here: never open the relay session (no
|
|
9556
|
+
// husk), reap the throwaway clone/run dir so no stale workspace is left, and
|
|
9557
|
+
// return without settling. Runs BEFORE the try/finally below, so the run-dir
|
|
9558
|
+
// reaping is done inline here.
|
|
9559
|
+
if (checkSetupAbort(abortSignal, { jobType, jobKey: job.jobKey, stage: 'relay-open', logger })) {
|
|
9560
|
+
// #222: after repository setup the producer may be active/retry-pending, so
|
|
9561
|
+
// discard it before returning too — a late create would otherwise mint an
|
|
9562
|
+
// orphaned instance after the runner exits, and an active one stays
|
|
9563
|
+
// unfinalized. This return only reaped the run dir before.
|
|
9564
|
+
await discardAgentInstanceProducer();
|
|
9565
|
+
if (runDir) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(runDir); }
|
|
9566
|
+
if (isContainer) liveRunIds.delete(runId);
|
|
9567
|
+
return;
|
|
9568
|
+
}
|
|
9569
|
+
|
|
9224
9570
|
let relaySession = null;
|
|
9225
9571
|
if (agenticPlane) {
|
|
9226
9572
|
// #229: thread the correlation join keys + a lazy AgentInstance-key getter
|
|
@@ -9592,6 +9938,11 @@ async function workAgent(req, flags) {
|
|
|
9592
9938
|
worker: workerName,
|
|
9593
9939
|
workers: [{ id: workerName, types: jobTypes, capacity: 1 }],
|
|
9594
9940
|
autoWorkerId: autoMode ? workerName : undefined,
|
|
9941
|
+
// Explicit `--job-type` extras must outlive the first reconcile: the runtime's
|
|
9942
|
+
// reconcile rewrites the --auto worker's set from the engine scan, so pass the
|
|
9943
|
+
// extras through to be unioned into every reconcile write (else an explicit
|
|
9944
|
+
// subscription stops being served once reconcile succeeds).
|
|
9945
|
+
autoExtraTypes: autoMode && extraJobTypes.length > 0 ? extraJobTypes : undefined,
|
|
9595
9946
|
scope: autoScope,
|
|
9596
9947
|
// The ONE multiplexed host connection (issue #173): the runtime owns its
|
|
9597
9948
|
// connect/reconnect/resync + teardown lifecycle. Omitted (undefined) when the
|
|
@@ -9971,6 +10322,55 @@ function reconstructWorkArgs(flags) {
|
|
|
9971
10322
|
return out;
|
|
9972
10323
|
}
|
|
9973
10324
|
|
|
10325
|
+
// The c8ctl connection profile (its global `--profile` flag) resolved for THIS
|
|
10326
|
+
// invocation, for handing to `createClient(profile)`. c8ctl core builds the
|
|
10327
|
+
// plugin handler's third `ctx` argument with `ctx.profile = --profile override
|
|
10328
|
+
// ?? activeProfile` (index.js), then exposes a lazy `createClient(pluginProfile)`
|
|
10329
|
+
// — but this plugin creates its own client via `globalThis.c8ctl.createClient()`
|
|
10330
|
+
// and, before this, called it with NO profile, so `work`/`supervisor` always
|
|
10331
|
+
// connected to the ACTIVE session profile and silently ignored a per-invocation
|
|
10332
|
+
// `--profile <name>` (jwulf/c8ctl-plugin-nano#189). Threading this value into
|
|
10333
|
+
// `createClient(profile)` makes them honour `--profile` the way core c8ctl
|
|
10334
|
+
// commands do. Returns undefined when ctx carries no profile, so
|
|
10335
|
+
// `createClient(undefined)` resolves the active session profile itself (its own
|
|
10336
|
+
// documented default) — byte-identical to the old no-arg call. Pure.
|
|
10337
|
+
function resolveConnectionProfile(ctx) {
|
|
10338
|
+
const p = ctx && typeof ctx.profile === 'string' ? ctx.profile.trim() : '';
|
|
10339
|
+
return p || undefined;
|
|
10340
|
+
}
|
|
10341
|
+
|
|
10342
|
+
// The EXPLICIT `--profile <name>` override only (distinct from the active
|
|
10343
|
+
// session profile), for FORWARDING to spawned `nano work` children. When an
|
|
10344
|
+
// operator runs `supervisor start --worker <p> --profile <conn>` (or
|
|
10345
|
+
// `supervisor add … --profile <conn>`), each supervised worker is a fresh
|
|
10346
|
+
// `c8ctl nano work` process that must connect to <conn>, not the daemon's/
|
|
10347
|
+
// session's active profile. c8ctl strips the global `--profile` before the
|
|
10348
|
+
// plugin parser sees it, so it never lands in `flags`; we recover it from
|
|
10349
|
+
// `ctx.profile` and re-emit it as a `--profile` token in the child argv (c8ctl
|
|
10350
|
+
// core parses it position-independently as a global). Returns undefined when no
|
|
10351
|
+
// override was given — i.e. `ctx.profile` is absent or merely equals the active
|
|
10352
|
+
// session profile — so the child inherits the active profile exactly as before
|
|
10353
|
+
// (no spurious pin). Pure.
|
|
10354
|
+
function explicitConnectionProfile(ctx) {
|
|
10355
|
+
const p = resolveConnectionProfile(ctx);
|
|
10356
|
+
if (!p) return undefined;
|
|
10357
|
+
const active = globalThis.c8ctl && typeof globalThis.c8ctl.activeProfile === 'string'
|
|
10358
|
+
? globalThis.c8ctl.activeProfile
|
|
10359
|
+
: undefined;
|
|
10360
|
+
return p === active ? undefined : p;
|
|
10361
|
+
}
|
|
10362
|
+
|
|
10363
|
+
// Append the explicit connection-profile override (if any) to a reconstructed
|
|
10364
|
+
// `work` argv tail as a c8ctl global `--profile <conn>` token, so a supervised
|
|
10365
|
+
// worker connects to the profile named on the `supervisor start`/`add` command
|
|
10366
|
+
// line rather than the active session profile (jwulf/c8ctl-plugin-nano#189).
|
|
10367
|
+
// A no-op when no `--profile` override was passed. Pure.
|
|
10368
|
+
function withConnectionProfileArg(workArgs, ctx) {
|
|
10369
|
+
const conn = explicitConnectionProfile(ctx);
|
|
10370
|
+
const base = Array.isArray(workArgs) ? workArgs : [];
|
|
10371
|
+
return conn ? [...base, '--profile', conn] : base;
|
|
10372
|
+
}
|
|
10373
|
+
|
|
9974
10374
|
/**
|
|
9975
10375
|
* Sanitize one token for use inside a worker name: keep `[A-Za-z0-9._-]`,
|
|
9976
10376
|
* collapse every other run to a single `-`, and trim leading/trailing
|
|
@@ -11393,13 +11793,17 @@ async function startSupervisorWithServicePolicy(logger = getLogger()) {
|
|
|
11393
11793
|
return startSupervisorDaemon({ adoptOnly: serviceOwned });
|
|
11394
11794
|
}
|
|
11395
11795
|
|
|
11396
|
-
async function supervisorStartCmd(req, flags) {
|
|
11796
|
+
async function supervisorStartCmd(req, flags, ctx) {
|
|
11397
11797
|
const logger = getLogger();
|
|
11398
11798
|
const state = await startSupervisorWithServicePolicy(logger);
|
|
11399
11799
|
logger.info(`Supervisor daemon running (pid ${state.pid}).`);
|
|
11400
11800
|
|
|
11401
11801
|
const specs = normalizeArgList(flags?.worker);
|
|
11402
|
-
|
|
11802
|
+
// Forward c8ctl's global `--profile <conn>` (when it overrides the active
|
|
11803
|
+
// session profile) to every spawned worker, so `supervisor start --worker <p>
|
|
11804
|
+
// --profile <conn>` pins the fleet to <conn> instead of silently connecting to
|
|
11805
|
+
// the active session engine (jwulf/c8ctl-plugin-nano#189).
|
|
11806
|
+
const workArgs = withConnectionProfileArg(reconstructWorkArgs(flags), ctx);
|
|
11403
11807
|
// `--name` names a single launched worker. With several `--worker` specs a lone
|
|
11404
11808
|
// name can't apply to all of them, so honour it only for a single spec and let
|
|
11405
11809
|
// the rest auto-name; warn so the intent isn't silently dropped.
|
|
@@ -11459,7 +11863,7 @@ async function supervisorStatusCmd() {
|
|
|
11459
11863
|
printSupervisorStatus(logger, statusFromState(running));
|
|
11460
11864
|
}
|
|
11461
11865
|
|
|
11462
|
-
async function supervisorAddCmd(req, flags) {
|
|
11866
|
+
async function supervisorAddCmd(req, flags, ctx) {
|
|
11463
11867
|
const logger = getLogger();
|
|
11464
11868
|
// The positional profile is what runs; `--name` names this worker instance
|
|
11465
11869
|
// (forwarded to the child as `nano work … --name`, and used as its supervisor
|
|
@@ -11478,7 +11882,10 @@ async function supervisorAddCmd(req, flags) {
|
|
|
11478
11882
|
process.exit(1);
|
|
11479
11883
|
}
|
|
11480
11884
|
await startSupervisorWithServicePolicy(logger);
|
|
11481
|
-
|
|
11885
|
+
// Forward the global `--profile <conn>` override to the spawned worker(s) so
|
|
11886
|
+
// `supervisor add … --profile <conn>` connects them to <conn>, matching
|
|
11887
|
+
// `supervisor start` (jwulf/c8ctl-plugin-nano#189).
|
|
11888
|
+
const workArgs = withConnectionProfileArg(reconstructWorkArgs(flags), ctx);
|
|
11482
11889
|
let added = 0;
|
|
11483
11890
|
let failed = 0;
|
|
11484
11891
|
for (let i = 0; i < count; i++) {
|
|
@@ -12273,7 +12680,7 @@ async function maybeReparentOrWarnOnStart(logger) {
|
|
|
12273
12680
|
}
|
|
12274
12681
|
|
|
12275
12682
|
/** Dispatch the `supervisor` subcommand's action. */
|
|
12276
|
-
async function supervisorCommand(req, flags) {
|
|
12683
|
+
async function supervisorCommand(req, flags, ctx) {
|
|
12277
12684
|
const action = (req.positional[0] || '').toLowerCase();
|
|
12278
12685
|
switch (action) {
|
|
12279
12686
|
case '__daemon':
|
|
@@ -12288,7 +12695,7 @@ async function supervisorCommand(req, flags) {
|
|
|
12288
12695
|
return;
|
|
12289
12696
|
}
|
|
12290
12697
|
case 'start':
|
|
12291
|
-
await supervisorStartCmd(req, flags);
|
|
12698
|
+
await supervisorStartCmd(req, flags, ctx);
|
|
12292
12699
|
return;
|
|
12293
12700
|
case 'install':
|
|
12294
12701
|
await supervisorInstallCmd();
|
|
@@ -12302,7 +12709,7 @@ async function supervisorCommand(req, flags) {
|
|
|
12302
12709
|
await supervisorStatusCmd();
|
|
12303
12710
|
return;
|
|
12304
12711
|
case 'add':
|
|
12305
|
-
await supervisorAddCmd(req, flags);
|
|
12712
|
+
await supervisorAddCmd(req, flags, ctx);
|
|
12306
12713
|
return;
|
|
12307
12714
|
case 'remove':
|
|
12308
12715
|
case 'rm':
|
|
@@ -15086,7 +15493,10 @@ export {
|
|
|
15086
15493
|
diffJobTypes,
|
|
15087
15494
|
parseJobTypeFlags,
|
|
15088
15495
|
scanAgentTaskLeaves,
|
|
15089
|
-
|
|
15496
|
+
serviceTaskIsExternalAgent,
|
|
15497
|
+
externalAgentElementIds,
|
|
15498
|
+
serviceTaskOptsOutOfAutoSubscribe,
|
|
15499
|
+
autoSubscribeOptOutElementIds,
|
|
15090
15500
|
readDeployedAgentJobTypes,
|
|
15091
15501
|
resolveAutoJobTypes,
|
|
15092
15502
|
loadSupervisorRuntime,
|
|
@@ -15104,6 +15514,7 @@ export {
|
|
|
15104
15514
|
withIpv4FirstNodeOptions,
|
|
15105
15515
|
DNS_RESULT_ORDER_IPV4_FIRST,
|
|
15106
15516
|
workAgent,
|
|
15517
|
+
checkSetupAbort,
|
|
15107
15518
|
derivePollTimeoutMs,
|
|
15108
15519
|
AGENT_TASK_NS,
|
|
15109
15520
|
AGENT_RESULT_KEY,
|
|
@@ -15115,6 +15526,9 @@ export {
|
|
|
15115
15526
|
};
|
|
15116
15527
|
export {
|
|
15117
15528
|
reconstructWorkArgs,
|
|
15529
|
+
resolveConnectionProfile,
|
|
15530
|
+
explicitConnectionProfile,
|
|
15531
|
+
withConnectionProfileArg,
|
|
15118
15532
|
supervisorWorkerId,
|
|
15119
15533
|
autoWorkerName,
|
|
15120
15534
|
sanitizeNameToken,
|
|
@@ -15150,6 +15564,7 @@ export {
|
|
|
15150
15564
|
runSupervisorDaemon,
|
|
15151
15565
|
startSupervisorDaemon,
|
|
15152
15566
|
supervisorRequest,
|
|
15567
|
+
supervisorStartCmd,
|
|
15153
15568
|
supervisorAddCmd,
|
|
15154
15569
|
runningSupervisor,
|
|
15155
15570
|
readSupervisorState,
|
|
@@ -15339,7 +15754,7 @@ export const commands = {
|
|
|
15339
15754
|
manifest: { type: 'string', description: `workforce: manifest name to operate on (default ${DEFAULT_WORKFORCE_MANIFEST}); each subcommand reads/writes <stateHome>/workforce/<name>.json. Renamed from --profile (which now collides with c8ctl's global connection-profile flag).` },
|
|
15340
15755
|
roles: { type: 'string', description: 'workforce add: comma-separated role list for the entry (→ --job-type <rank>:<role> at start); mutually exclusive with --auto' },
|
|
15341
15756
|
},
|
|
15342
|
-
handler: async (args, flags) => {
|
|
15757
|
+
handler: async (args, flags, ctx) => {
|
|
15343
15758
|
const logger = getLogger();
|
|
15344
15759
|
const req = parseRequest(args, flags);
|
|
15345
15760
|
|
|
@@ -15396,10 +15811,10 @@ export const commands = {
|
|
|
15396
15811
|
await assignCapabilities(req, flags);
|
|
15397
15812
|
break;
|
|
15398
15813
|
case 'work':
|
|
15399
|
-
await workAgent(req, flags);
|
|
15814
|
+
await workAgent(req, flags, ctx);
|
|
15400
15815
|
break;
|
|
15401
15816
|
case 'supervisor':
|
|
15402
|
-
await supervisorCommand(req, flags);
|
|
15817
|
+
await supervisorCommand(req, flags, ctx);
|
|
15403
15818
|
break;
|
|
15404
15819
|
case 'workforce':
|
|
15405
15820
|
await workforceCommand(req, flags);
|