@cotal-ai/manager 0.126.2 → 0.127.0

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/dist/manager.js CHANGED
@@ -261,6 +261,10 @@ function stopCauseText(cause) {
261
261
  default: return cause;
262
262
  }
263
263
  }
264
+ const TURN_PARTICIPATION_TTL_MS = 5 * 60_000;
265
+ function participantKey(owner, actor, uid) {
266
+ return `${owner}.${actor}.${uid}`;
267
+ }
264
268
  function parseTurnNote(raw) {
265
269
  let o;
266
270
  try {
@@ -544,6 +548,7 @@ export class Manager {
544
548
  * window would hear `not-found` again.
545
549
  */
546
550
  turnAcceptances = new Map();
551
+ participants = new Map();
547
552
  /** Every relayed turn awaiting its seat's yield, by {@link goalKey}. The seat's `turn-pending` pull
548
553
  * scans it; the deadline sweep drives expiry; a seat reap stamps its entries `seatDiedAt`, which
549
554
  * the deadline terminal carries as `agentDownAt` so the run reads that death as L4002.
@@ -699,7 +704,7 @@ export class Manager {
699
704
  throw new Error("execution: none requires boolean pooled and a non-null remote authority object when supplied");
700
705
  if (opts.execution === "none" && (opts.runtime !== undefined || (!!opts.pooled !== !!opts.remoteAuthority)))
701
706
  throw new Error("execution: none requires pooled signerless remote authority for remote composition, or genuine local authority, and no runtime option");
702
- if (opts.execution === "none" && opts.remoteAuthority && ["renewExecutor", "renewStandingBundle", "mintSessionServing", "mintRetirementRequester", "prepareAgentRetirement", "validateRetainedAgent", "scanGoalIndex", "authorizeAdmin"].some((key) => typeof opts.remoteAuthority?.[key] !== "function"))
707
+ if (opts.execution === "none" && opts.remoteAuthority && ["renewExecutor", "renewStandingBundle", "mintSessionServing", "mintRetirementRequester", "prepareAgentRetirement", "validateRetainedAgent", "scanGoalIndex", "authorizeAdmin", "authorizeParticipation"].some((key) => typeof opts.remoteAuthority?.[key] !== "function"))
703
708
  throw new Error("execution: none requires complete closed remote authority callbacks before construction");
704
709
  if (opts.pooled && (!opts.remoteAuthority || (opts.execution !== "none" && (opts.runtime === undefined || opts.runtime === "auto" || opts.runtime === "pty"))))
705
710
  throw new Error("pooled control requires signerless remote authority and an explicit non-PTY runtime; local custodial execution is forbidden");
@@ -3089,6 +3094,83 @@ export class Manager {
3089
3094
  if (p.run !== args.runId || p.step !== args.stepKey || addressedToken !== open.token)
3090
3095
  throw new EpEnvelopeError("permission-denied", `run-answer is allowed to ${agent.name} only for the open pause named by its pending ask or escalation relay; ${args.runId} ${args.stepKey} is not that pause`);
3091
3096
  }
3097
+ /** Find one exact live presence row. Presence is not authority, but a participation lease with no
3098
+ * live addressee would authorize a durable turn nobody can receive. Duplicate exact rows are
3099
+ * refused rather than selected by order. */
3100
+ liveParticipantPresence(caller) {
3101
+ const principal = principalKey(caller.owner, caller.actor).key;
3102
+ const rows = this.ep.getRoster().filter((p) => p.card.id === principal && p.lifecycleUid === caller.uid && p.status !== "offline");
3103
+ if (rows.length !== 1)
3104
+ throw new EpEnvelopeError("expired", rows.length === 0
3105
+ ? `personal participant ${principal} (lifecycle ${caller.uid}) has no exact live presence`
3106
+ : `personal participant ${principal} (lifecycle ${caller.uid}) has multiple exact live presence rows; no row was selected`, undefined, "not-executed");
3107
+ return rows[0];
3108
+ }
3109
+ /** Authorize and renew one personal native participant. Local user-auth authority reads the exact
3110
+ * managed foreground row fresh; a signerless manager asks its registered host. The lease is held
3111
+ * only in memory and is therefore renewed after reconnect or manager restart. */
3112
+ async serveTurnParticipation(caller) {
3113
+ if (this.execution === "none")
3114
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal(), undefined, "not-executed");
3115
+ if (!/^pa_[a-z2-7]{26}$/.test(caller.actor))
3116
+ throw new EpEnvelopeError("permission-denied", "workflow turn participation is reserved for approved personal conversation actors", undefined, "not-executed");
3117
+ if (!this.remoteAuthority)
3118
+ throw new EpEnvelopeError("unimplemented", "personal workflow participation requires a registered issuing host with fresh lifecycle authority", undefined, "not-executed");
3119
+ if (!this.remoteAuthority.authorizeParticipation)
3120
+ throw new EpEnvelopeError("unimplemented", "the issuing host supplies no closed personal participation authorization", undefined, "not-executed");
3121
+ const authorized = await this.remoteAuthority.authorizeParticipation({ owner: caller.owner, actor: caller.actor, lifecycleUid: caller.uid });
3122
+ if (!authorized)
3123
+ throw new EpEnvelopeError("permission-denied", "the exact personal lifecycle is not currently foreground-authorized with run scope", undefined, "not-executed");
3124
+ const presence = this.liveParticipantPresence(caller);
3125
+ const expiresAt = Date.now() + TURN_PARTICIPATION_TTL_MS;
3126
+ const generation = caller.generation;
3127
+ const participant = {
3128
+ name: presence.card.name, owner: caller.owner, actor: caller.actor, uid: caller.uid,
3129
+ ...(generation !== undefined ? { generation } : {}),
3130
+ expiresAt,
3131
+ };
3132
+ this.participants.set(participantKey(caller.owner, caller.actor, caller.uid), participant);
3133
+ return { owner: caller.owner, actor: caller.actor, lifecycleUid: caller.uid, name: participant.name,
3134
+ managerInstanceId: this.managerInstanceId, expiresAt };
3135
+ }
3136
+ currentParticipant(owner, actor, uid) {
3137
+ const key = participantKey(owner, actor, uid);
3138
+ const p = this.participants.get(key);
3139
+ if (!p)
3140
+ return undefined;
3141
+ if (p.expiresAt <= Date.now()) {
3142
+ this.participants.delete(key);
3143
+ return undefined;
3144
+ }
3145
+ try {
3146
+ this.liveParticipantPresence({ owner, actor, uid });
3147
+ }
3148
+ catch {
3149
+ this.participants.delete(key);
3150
+ return undefined;
3151
+ }
3152
+ return p;
3153
+ }
3154
+ /** A managed seat or a finite authorized personal participant. Only the first carries custody. */
3155
+ turnTarget(ctx) {
3156
+ if (this.execution === "none")
3157
+ throw new EpEnvelopeError("unimplemented", this.executionRefusal());
3158
+ const t = ctx.request.target;
3159
+ const managed = this.findManagedByTarget(t);
3160
+ if (managed)
3161
+ return { kind: "managed", agent: managed, name: managed.name };
3162
+ const participant = this.currentParticipant(t.owner, t.actor, t.lifecycleUid);
3163
+ if (participant)
3164
+ return { kind: "personal", participant, name: participant.name };
3165
+ throw new EpEnvelopeError("expired", `target ${t.owner}.${t.actor} (lifecycle ${t.lifecycleUid}) is neither a live managed agent nor a current personal participant of this manager`, undefined, "not-executed");
3166
+ }
3167
+ /** Resolve whether an exact turn executor lifecycle is current. */
3168
+ turnExecutorEpoch(name, owner, actor, uid) {
3169
+ const managed = this.agents.get(name);
3170
+ if (managed && this.managedPrincipal(managed) === principalKey(owner, actor).key && managed.lifecycleUid === uid)
3171
+ return 0;
3172
+ return this.currentParticipant(owner, actor, uid)?.name === name ? 0 : null;
3173
+ }
3092
3174
  /** A baseline seat may amend only an answer recorded under its own name: it changes its own
3093
3175
  * position, never another answerer's. Explicit `run` holders and unmanaged operator instruments
3094
3176
  * keep the reach they have to answer, as {@link authorizeRunAnswer} does. */
@@ -3292,12 +3374,33 @@ export class Manager {
3292
3374
  // caller must hold owner-equality or admin over the TARGET seat — written out for the same
3293
3375
  // reason `input` is (a shared policy, not a shared body).
3294
3376
  turn: (ctx) => this.serveGated(ctx, async () => {
3295
- const a = targetAgent(ctx);
3296
- const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
3297
- if (denied)
3298
- throw new EpEnvelopeError("permission-denied", denied, undefined, "not-executed");
3299
- return this.serveTurnGoal(ctx, a);
3377
+ const target = this.turnTarget(ctx);
3378
+ if (target.kind === "managed") {
3379
+ const denied = await this.authorizeNamed(target.agent, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
3380
+ if (denied)
3381
+ throw new EpEnvelopeError("permission-denied", denied, undefined, "not-executed");
3382
+ }
3383
+ else {
3384
+ const admitted = this.runHosting?.admittedCaller(ctx.subject.caller);
3385
+ if (!admitted || admitted.owner !== target.participant.owner || admitted.actor !== target.participant.actor || admitted.uid !== target.participant.uid)
3386
+ throw new EpEnvelopeError("permission-denied", "a personal participant may be turned only by the workflow run admitted for that exact owner, actor and lifecycle", undefined, "not-executed");
3387
+ if (!this.remoteAuthority?.authorizeParticipation)
3388
+ throw new EpEnvelopeError("unimplemented", "the issuing host supplies no closed personal participation authorization", undefined, "not-executed");
3389
+ const authorized = await this.remoteAuthority.authorizeParticipation({
3390
+ owner: target.participant.owner,
3391
+ actor: target.participant.actor,
3392
+ lifecycleUid: target.participant.uid,
3393
+ });
3394
+ if (!authorized) {
3395
+ this.participants.delete(participantKey(target.participant.owner, target.participant.actor, target.participant.uid));
3396
+ throw new EpEnvelopeError("permission-denied", "the exact personal lifecycle is no longer foreground-authorized with run scope", undefined, "not-executed");
3397
+ }
3398
+ }
3399
+ return this.serveTurnGoal(ctx, target);
3300
3400
  }),
3401
+ // A personal native lifecycle must opt in explicitly. The command is self-targeted and returns
3402
+ // only a finite relay lease. It never inserts a ManagedAgent or grants stop/attach/input custody.
3403
+ turnParticipate: (ctx) => this.serveGated(ctx, () => this.serveTurnParticipation(ctx.subject.caller)),
3301
3404
  // The seat's own half of the relay (`manager.self`, stop-self's tier): a seat pulls the
3302
3405
  // turns addressed to ITS incarnation and yields them; no reach beyond itself exists here.
3303
3406
  turnPending: (ctx) => this.serveGated(ctx, () => this.turnPendingFor(ctx.subject.caller)),
@@ -7592,7 +7695,8 @@ export class Manager {
7592
7695
  for (const a of this.agents.values())
7593
7696
  if (a.id === key)
7594
7697
  return { lifecycleUid: a.lifecycleUid, mappingRevision: 0 };
7595
- return undefined;
7698
+ const participant = [...this.participants.values()].find((p) => p.owner === t.owner && p.actor === t.actor && this.currentParticipant(p.owner, p.actor, p.uid) === p);
7699
+ return participant ? { lifecycleUid: participant.uid, mappingRevision: 0 } : undefined;
7596
7700
  }
7597
7701
  if (t.owner === DEV_OWNER) {
7598
7702
  for (const a of this.agents.values())
@@ -8785,7 +8889,7 @@ export class Manager {
8785
8889
  * path (idempotent retry map, index-CAS-before-bind, create-only bindGoal) but runs it INLINE:
8786
8890
  * there is no launch to hand off, so the accept either completes durably or unwinds its own
8787
8891
  * bound goal with a `failed` terminal before refusing. */
8788
- async serveTurnGoal(ctx, a) {
8892
+ async serveTurnGoal(ctx, target) {
8789
8893
  const gw = this.goalWriter;
8790
8894
  if (!gw)
8791
8895
  throw new EpEnvelopeError("unavailable", "the manager goal-writer connection is not standing; turn-as-action cannot accept (SPEC 13.6)");
@@ -8795,6 +8899,7 @@ export class Manager {
8795
8899
  if (!this.goalReconcileDone)
8796
8900
  throw new EpEnvelopeError("unavailable", "the manager is still reconciling accepted goals at boot; retry shortly (SPEC 13.6)");
8797
8901
  const t = ctx.request.target; // targeted command: the serve boundary enforced presence + currency
8902
+ const name = target.name;
8798
8903
  const goalId = ctx.request.id;
8799
8904
  const { fingerprint } = submissionFingerprint(ctx.request, ctx.subject);
8800
8905
  const ref = goalRefOf(ctx.subject, goalId);
@@ -8808,7 +8913,7 @@ export class Manager {
8808
8913
  return prior;
8809
8914
  }
8810
8915
  // Refused here, before anything durable, so the caller's goal id stays free for another seat.
8811
- this.assertSeatTurnFree(a, t, goalId);
8916
+ this.assertSeatTurnFree(name, t, goalId);
8812
8917
  const raw = (ctx.request.args ?? {});
8813
8918
  const payload = String(raw.payload);
8814
8919
  const deadlineAt = raw.deadlineAt === undefined ? acceptedAt + Number(raw.deadlineMs) : Number(raw.deadlineAt);
@@ -8829,7 +8934,7 @@ export class Manager {
8829
8934
  throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" already ended ${ended.state}; a bound goal is never accepted again (SPEC 13.6)`);
8830
8935
  // Index-CAS-before-bind, exactly as spawn: the entry (floor + note) is the durable relay
8831
8936
  // record a successor rebuilds from, so it must exist before the acceptance is servable.
8832
- const idx = await recordGoalIndex(gw.ctx, ref, executor.lifecycleUid, { name: a.name, owner: t.owner, actor: t.actor, uid: t.lifecycleUid, readinessDeadlineMs: deadlineMs }, note);
8937
+ const idx = await recordGoalIndex(gw.ctx, ref, executor.lifecycleUid, { name, owner: t.owner, actor: t.actor, uid: t.lifecycleUid, readinessDeadlineMs: deadlineMs }, note);
8833
8938
  if (!idx.recorded && idx.existing.iid !== executor.lifecycleUid)
8834
8939
  throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" was accepted by instance "${idx.existing.iid}"; that instance owns its relay and this attempt provisions nothing (SPEC 13.6)`);
8835
8940
  const b = await bindGoal(gw.ctx, ref, fingerprint);
@@ -8877,7 +8982,7 @@ export class Manager {
8877
8982
  });
8878
8983
  // Again after the awaits, with no await before the maps are written: another caller's
8879
8984
  // accept of this id to this seat may have finished meanwhile, and this unwinds the loser.
8880
- this.assertSeatTurnFree(a, t, goalId);
8985
+ this.assertSeatTurnFree(name, t, goalId);
8881
8986
  }
8882
8987
  catch (e) {
8883
8988
  // The accept is inline (no launch closure to fail later), so a post-bind throw unwinds HERE:
@@ -8894,7 +8999,7 @@ export class Manager {
8894
8999
  ref, now: Date.now(), cause: "complete", state: "failed", data: { error: msg },
8895
9000
  committer: { instanceId: this.managerInstanceId, epoch: executor.epoch },
8896
9001
  executor: { lifecycleUid: t.lifecycleUid, epoch: 0 },
8897
- resolveCurrentEpoch: (target) => this.agents.get(a.name)?.lifecycleUid === target.lifecycleUid ? 0 : null,
9002
+ resolveCurrentEpoch: (current) => this.turnExecutorEpoch(name, t.owner, t.actor, current.lifecycleUid),
8898
9003
  });
8899
9004
  await clearGoalIndex(gw.ctx, ref);
8900
9005
  }
@@ -8905,7 +9010,7 @@ export class Manager {
8905
9010
  }
8906
9011
  const pending = {
8907
9012
  ref, goalId,
8908
- seat: { name: a.name, owner: t.owner, actor: t.actor, uid: t.lifecycleUid },
9013
+ seat: { name, owner: t.owner, actor: t.actor, uid: t.lifecycleUid },
8909
9014
  payload, acceptedAt, deadlineAt,
8910
9015
  holdToken, holdEpoch: executor.epoch,
8911
9016
  ...(handoffFrom !== undefined ? { handoffFrom } : {}),
@@ -8913,7 +9018,7 @@ export class Manager {
8913
9018
  this.pendingTurns.set(key, pending);
8914
9019
  this.ensureTurnSweep();
8915
9020
  this.noticeSeatTurns(pending.seat);
8916
- const acceptance = { name: a.name, owner: t.owner, actor: t.actor, uid: t.lifecycleUid, goalId, fingerprint, deadlineAt, executor };
9021
+ const acceptance = { name, owner: t.owner, actor: t.actor, uid: t.lifecycleUid, goalId, fingerprint, deadlineAt, executor };
8917
9022
  this.turnAcceptances.set(key, { acceptance, ref });
8918
9023
  this.emitGoalProgress(ref, executor.epoch, { phase: "relayed" });
8919
9024
  return acceptance;
@@ -8954,7 +9059,8 @@ export class Manager {
8954
9059
  return;
8955
9060
  // An issued seat credential reads only the versioned rail, under the generation it was issued.
8956
9061
  const a = this.agents.get(seat.name);
8957
- const generation = a?.lifecycleUid === seat.uid ? a.issued?.generation : undefined;
9062
+ const participant = this.currentParticipant(seat.owner, seat.actor, seat.uid);
9063
+ const generation = a?.lifecycleUid === seat.uid ? a.issued?.generation : participant?.generation;
8958
9064
  const caller = { owner: seat.owner, actor: seat.actor, uid: seat.uid, ...(generation !== undefined ? { generation } : {}) };
8959
9065
  try {
8960
9066
  serve.nc.publish(turnNoticeSubject(this.space, this.managerInstanceId, serve.grant.epoch, caller), new TextEncoder().encode(JSON.stringify({ v: 1 })));
@@ -8965,9 +9071,9 @@ export class Manager {
8965
9071
  }
8966
9072
  /** A seat names a turn by goal id alone when it yields, so one incarnation holding two callers'
8967
9073
  * turns under one id could not say which of the two it answered. */
8968
- assertSeatTurnFree(a, t, goalId) {
9074
+ assertSeatTurnFree(name, t, goalId) {
8969
9075
  if (this.seatTurnKey({ owner: t.owner, actor: t.actor, uid: t.lifecycleUid }, goalId) !== undefined)
8970
- throw new EpEnvelopeError("failed-precondition", `seat "${a.name}" already holds another caller's turn "${goalId}"; a seat yields by goal id, so it is never handed two turns under one (SPEC 13.6)`);
9076
+ throw new EpEnvelopeError("failed-precondition", `seat "${name}" already holds another caller's turn "${goalId}"; a seat yields by goal id, so it is never handed two turns under one (SPEC 13.6)`);
8971
9077
  }
8972
9078
  /** The relay key of the turn addressed to seat incarnation `c` under `goalId`, pending or kept
8973
9079
  * for a retried yield. The accept path never hands one incarnation two turns under one goal
@@ -9051,7 +9157,7 @@ export class Manager {
9051
9157
  ref: p.ref, now: at, cause: "complete", state: "succeeded", data,
9052
9158
  committer: { instanceId: this.managerInstanceId, epoch },
9053
9159
  executor: { lifecycleUid: p.seat.uid, epoch: 0 },
9054
- resolveCurrentEpoch: (target) => this.agents.get(p.seat.name)?.lifecycleUid === target.lifecycleUid ? 0 : null,
9160
+ resolveCurrentEpoch: (target) => this.turnExecutorEpoch(p.seat.name, p.seat.owner, p.seat.actor, target.lifecycleUid),
9055
9161
  });
9056
9162
  this.emitGoalProgress(p.ref, epoch, { phase: "terminal", state: fact.state, ...(fact.data !== undefined ? { data: fact.data } : {}) });
9057
9163
  await clearGoalIndex(gw.ctx, p.ref);