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/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`). As of
3093
- // `@nanobpm/agentic@0.4.0` its `scanTaskDefinitions(xml)` tags every leaf with a
3094
- // canonical `agentic: boolean` true iff the service task declares a
3095
- // `<zeebe:linkedResource … linkName="prompt">` base-prompt side-car (its internal
3096
- // `hasPromptLink`). That flag is the SINGLE SOURCE OF TRUTH for agentic-ness (see
3097
- // the package's `demand/taskdef.d.ts` and nano-workforce SPEC "Agent job
3098
- // contract"): every external agent task delivers its base prompt through a
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", this plugin
3104
- // CONSUMES that flag rather than re-implementing the scan, so the detector can
3105
- // never drift out of lock-step with the package again (as it did in #95, when a
3106
- // local copy keyed on the legacy `io.nanobpm.agentTask` header missed the current
3107
- // linked-prompt marker). Advertise the raw job-type string the engine matches
3108
- // (`senior:plan`) verbatim — colon-named types are NOT forced through the agentic
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
- // Legacy (pre-nano-workforce#203) agent-task marker: a service task carried the
3113
- // agent's prompt in an `io.nanobpm.agentTask.*` `<zeebe:header>` rather than a
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
- // True when `body` (a service task's inner XML) declares any
3125
- // `io.nanobpm.agentTask*` `<zeebe:header>` key — the sole such header on a
3126
- // pre-#203 agent service task. The regex is compiled once (it is called once
3127
- // per matched `<serviceTask>` during `--auto` scans, so recompiling per call
3128
- // would allocate needlessly across many deployed definitions).
3129
- const AGENT_TASK_HEADER_RE = new RegExp(
3130
- `<(?:\\w+:)?header\\b[^>]*\\bkey\\s*=\\s*(["'])${AGENT_TASK_NS.replace(/[.]/g, '\\.')}(?:\\.[^"']*)?\\1`,
3131
- 'i'
3132
- );
3133
- function serviceTaskHasAgentHeader(body) {
3134
- return AGENT_TASK_HEADER_RE.test(String(body || ''));
3135
- }
3136
-
3137
- // The set of service-task element ids in `xml` bearing the legacy agent-task
3138
- // header. Correlates back to `scanTaskDefinitions` leaves by `elementId`, so a
3139
- // leaf is treated as legacy-agentic only when it ALSO has a `taskDefinition type`
3140
- // (the package only yields leaves that do) — matching the issue rule "prompt link
3141
- // OR legacy header, AND a non-empty task type".
3142
- function legacyAgentHeaderElementIds(xml) {
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 src = String(xml || '');
3145
- // Cheap guard: a legacy agent-task header always contains the literal
3146
- // `io.nanobpm.agentTask` namespace, so a document lacking that substring
3147
- // cannot match skip the full `<serviceTask>` walk entirely. This avoids
3148
- // parsing every deployed definition in `--auto` enrolment loops when none
3149
- // carry the legacy marker.
3150
- if (!src.includes(AGENT_TASK_NS)) return ids;
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 (serviceTaskHasAgentHeader(m[3])) ids.add(m[2]);
3251
+ if (predicate(m[3])) ids.add(m[2]);
3156
3252
  }
3157
3253
  return ids;
3158
3254
  }
3159
3255
 
3160
- // Scan one deployed BPMN document for its *agent* task-definition leaves: the
3161
- // subset of `@nanobpm/agentic` `demand.scanTaskDefinitions(xml)` leaves whose
3162
- // canonical `agentic` flag is set (i.e. the service task declares a
3163
- // `linkName="prompt"` linked resource) OR — for backward compatibility with
3164
- // pre-#203 deployments — which carry the legacy `io.nanobpm.agentTask` header.
3165
- // Returns `{ taskType, process }` leaves in first-occurrence order; a task
3166
- // matched by both signals is the same leaf, so it is emitted once. The published
3167
- // `scanTaskDefinitions` is INJECTED so this stays a pure, synchronous function;
3168
- // `readDeployedAgentJobTypes` supplies the real one from the lazily-imported
3169
- // demand surface (`agentic.mjs`).
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 legacyIds = legacyAgentHeaderElementIds(src);
3285
+ const externalIds = externalAgentElementIds(src);
3286
+ const optedOutIds = autoSubscribeOptOutElementIds(src);
3179
3287
  return scanTaskDefinitions(src)
3180
- .filter((leaf) => leaf.agentic || legacyIds.has(leaf.elementId))
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 `agentic` flag the
8595
- // `linkName="prompt"` linked-resource marker read by
8596
- // `@nanobpm/agentic`'s demand scanner), and the
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
- const camunda = globalThis.c8ctl.createClient();
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
- const workArgs = reconstructWorkArgs(flags);
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
- const workArgs = reconstructWorkArgs(flags);
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
- serviceTaskHasAgentHeader,
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);