@wowyuarm/dsh-agent-team 0.2.0 → 0.2.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.
Files changed (67) hide show
  1. package/README.md +5 -5
  2. package/README.zh.md +5 -5
  3. package/cordis.patch.yml +26 -12
  4. package/package.json +50 -47
  5. package/packages/agent-team/lib/attachments.js +63 -4
  6. package/packages/agent-team/lib/compaction-engine.js +18 -0
  7. package/packages/agent-team/lib/context-continuity-host.js +198 -4
  8. package/packages/agent-team/lib/context-judge.js +150 -0
  9. package/packages/agent-team/lib/index.js +482 -97
  10. package/packages/agent-team/lib/invariant.js +36 -12
  11. package/packages/agent-team/lib/ledger.js +2 -1
  12. package/packages/agent-team/lib/member-runtime.js +2 -1
  13. package/packages/agent-team/lib/recovery.js +53 -4
  14. package/packages/agent-team/lib/typert.host.js +138 -44
  15. package/packages/agent-team/lib/typert.remote-client.d.ts +3 -1
  16. package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
  17. package/packages/agent-team/lib/typert.remote-client.js +75 -35
  18. package/packages/agent-team/lib/types/attachments.d.ts +32 -2
  19. package/packages/agent-team/lib/types/attachments.d.ts.map +1 -1
  20. package/packages/agent-team/lib/types/compaction-engine.d.ts +19 -0
  21. package/packages/agent-team/lib/types/compaction-engine.d.ts.map +1 -0
  22. package/packages/agent-team/lib/types/context-continuity-host.d.ts +131 -4
  23. package/packages/agent-team/lib/types/context-continuity-host.d.ts.map +1 -1
  24. package/packages/agent-team/lib/types/context-judge.d.ts +82 -0
  25. package/packages/agent-team/lib/types/context-judge.d.ts.map +1 -0
  26. package/packages/agent-team/lib/types/index.d.ts +259 -17
  27. package/packages/agent-team/lib/types/index.d.ts.map +1 -1
  28. package/packages/agent-team/lib/types/invariant.d.ts +27 -5
  29. package/packages/agent-team/lib/types/invariant.d.ts.map +1 -1
  30. package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
  31. package/packages/agent-team/lib/types/member-runtime.d.ts +1 -1
  32. package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
  33. package/packages/agent-team/lib/types/recovery.d.ts +19 -6
  34. package/packages/agent-team/lib/types/recovery.d.ts.map +1 -1
  35. package/packages/agent-team/lib/types/types/requests-results.d.ts +28 -2
  36. package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
  37. package/packages/agent-team/lib/types/types.d.ts +9 -0
  38. package/packages/agent-team/lib/types/types.d.ts.map +1 -1
  39. package/packages/client-agent-team/lib/client.js +1006 -374
  40. package/packages/client-agent-team/lib/client.js.map +1 -1
  41. package/packages/client-agent-team/lib/types/client/{HumanSettingsSection.d.ts → TeamSettingsSection.d.ts} +12 -4
  42. package/packages/client-agent-team/lib/types/client/TeamSettingsSection.d.ts.map +1 -0
  43. package/packages/client-agent-team/lib/types/client/TeamSettingsSection.js +195 -0
  44. package/packages/client-agent-team/lib/types/client/attachment-preview.d.ts +7 -11
  45. package/packages/client-agent-team/lib/types/client/attachment-preview.d.ts.map +1 -1
  46. package/packages/client-agent-team/lib/types/client/attachment-preview.js +13 -3
  47. package/packages/client-agent-team/lib/types/client/context-judge.d.ts +64 -0
  48. package/packages/client-agent-team/lib/types/client/context-judge.d.ts.map +1 -0
  49. package/packages/client-agent-team/lib/types/client/context-judge.js +78 -0
  50. package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
  51. package/packages/client-agent-team/lib/types/client/index.js +75 -11
  52. package/packages/client-agent-team/lib/types/client/judge-form.d.ts +99 -0
  53. package/packages/client-agent-team/lib/types/client/judge-form.d.ts.map +1 -0
  54. package/packages/client-agent-team/lib/types/client/judge-form.js +208 -0
  55. package/packages/client-agent-team/lib/types/client/locales.d.ts +52 -6
  56. package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
  57. package/packages/client-agent-team/lib/types/client/locales.js +55 -6
  58. package/packages/tool-agent-team/README.md +6 -3
  59. package/packages/tool-agent-team/README.zh.md +6 -3
  60. package/packages/tool-agent-team/lib/context-tools.js +84 -112
  61. package/packages/tool-agent-team/lib/types/context-tools.d.ts +29 -13
  62. package/packages/tool-agent-team/lib/types/context-tools.d.ts.map +1 -1
  63. package/packages/agent-team/lib/pressure-policy.js +0 -235
  64. package/packages/agent-team/lib/types/pressure-policy.d.ts +0 -134
  65. package/packages/agent-team/lib/types/pressure-policy.d.ts.map +0 -1
  66. package/packages/client-agent-team/lib/types/client/HumanSettingsSection.d.ts.map +0 -1
  67. package/packages/client-agent-team/lib/types/client/HumanSettingsSection.js +0 -118
@@ -50,15 +50,17 @@ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
50
50
  import { scopeOf } from '@deepseek-ai/dsh-scope';
51
51
  import { Session, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session';
52
52
  import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy';
53
- import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
54
- import { ATTACHMENT_MAX_BYTES, attachmentPayloadPath, attachmentsRoot, copyPathAttachment, newAttachmentId, readAttachment, sanitizeMediaType, sweepAttachmentCache, validatePathAttachment, writeAttachment } from "./attachments.js";
53
+ import z from '@deepseek-ai/schemastery';
54
+ import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
55
+ import { ATTACHMENT_MAX_BYTES, attachmentPayloadPath, attachmentsRoot, copyPathAttachment, pathAttachmentId, readAttachment, removeAttachment, sanitizeMediaType, sweepAttachmentCache, validatePathAttachment, writeRequestScopedAttachment } from "./attachments.js";
55
56
  import { reportEnvironment } from "./environment-check.js";
56
57
  import { HUMAN_PROFILE_DEFAULT_NAME, HUMAN_PROFILE_REPO_URL, HUMAN_PROFILE_SETTINGS_NAMESPACE, HUMAN_PROFILE_SETTINGS_SCHEMA, HUMAN_PROFILE_VERSION, assertValidHumanName, normalizeHumanName, parseLegacyHumanProfile, planLegacyAdoption } from "./human-profile.js";
57
58
  import { humanAvatarsRoot, readHumanAvatar, removeHumanAvatar, writeHumanAvatar } from "./human-avatar.js";
58
59
  import { createHumanUpdateChecker } from "./human-update-check.js";
59
- import { PressurePolicyCoordinator } from "./pressure-policy.js";
60
- import { CONTEXT_CONTINUITY_PROJECTION_KEY, readContextTimeline } from '@wowyuarm/dsh-context-continuity';
61
- import { createTeamContextManagement, TEAM_CONTEXT_CODEC } from "./context-continuity-host.js";
60
+ import { AgentTeamInvariantError } from "./invariant.js";
61
+ import { createTeamContextManagement, TeamPressurePolicy, TEAM_CONTEXT_CODEC, TEAM_PRESSURE_GATE_SCHEMA } from "./context-continuity-host.js";
62
+ import { CONTEXT_CONTINUITY_PROJECTION_KEY, DEFAULT_GATE_IDLE_MS, DEFAULT_GATE_TOKENS, readContextTimeline } from '@wowyuarm/dsh-context-continuity';
63
+ import { TeamContextJudge } from "./context-judge.js";
62
64
  import { AGENT_TEAM_PLUGIN_ID, isAgentTeamSource } from "./context-source.js";
63
65
  import { boundaryByRef, carriedInputOf, checkpointByRef, checkpointRefFor, createTeamContextProjectionConfig, createTeamContextProjectionDefinition, foldTeamContextProjection, retainedTopicsThrough, TeamContextProjectionHost } from "./context-projection.js";
64
66
  import { AGENT_TEAM_HUMAN_MEMBER_ID, AgentTeamLedger, agentTeamHumanActor } from "./ledger.js";
@@ -106,6 +108,19 @@ const CONTEXT_HARD_LIMIT_CAP = 256_000;
106
108
  const CONTEXT_HANDOFF_AT_CAP = 200_000;
107
109
  const CONTEXT_HANDOFF_RESERVE = 8_000;
108
110
  const CONTEXT_SAFE_OUTPUT_RESERVE = 16_000;
111
+ /**
112
+ * The budget Team gives the long-gap gate's judge when the row states none.
113
+ *
114
+ * The engine's own default is five seconds, which is sized for a judge beside
115
+ * the Host. A hosted endpoint answers in up to about four seconds, and the gate
116
+ * hands the judge one second less than this deadline, so that default turns a
117
+ * slow-but-working judge into a coin flip — and the deployment that meets this
118
+ * problem is the one least able to diagnose it. Ten seconds leaves room for the
119
+ * slow tail while still bounding the wait on the path that starts a turn. A
120
+ * deployment that wants another number still sets `gate.judgeTimeoutMs`, which
121
+ * stays a row field.
122
+ */
123
+ const TEAM_JUDGE_TIMEOUT_DEFAULT_MS = 10_000;
109
124
  /**
110
125
  * Usage at or above which an acknowledged acceptance advises a fresh
111
126
  * rollover instead of keeping the context; capped by the route's effective
@@ -173,6 +188,29 @@ export class AgentTeamDmDeliveryError extends Error {
173
188
  this.name = 'AgentTeamDmDeliveryError';
174
189
  }
175
190
  }
191
+ /**
192
+ * The judge's fields as one row form. Every one is volatile, because that is
193
+ * what makes the settings service offer them and what lets a key written in the
194
+ * Team settings surface mount the judge without restarting the Host; the key
195
+ * alone is a secret, so a read reports that it is set and never its value.
196
+ */
197
+ const TEAM_JUDGE_SETTINGS_SCHEMA = z.object({
198
+ apiKey: z.string().role('secret').volatile().description('API key for the relatedness judge. Setting one enables jev-backed context management; clearing it disables it.'),
199
+ apiKeyEnv: z.string().volatile().description('Environment variable holding the key, when the key is not stated here. Default: TYPESAFE_API_KEY'),
200
+ model: z.string().volatile().description('Model id sent with every judgement. Default: the jev plugin\'s own default.'),
201
+ apiBase: z.string().volatile().description('Endpoint base the judgements go to. Default: the jev plugin\'s own default.'),
202
+ });
203
+ /**
204
+ * The Host row's whole schema: the profile fields verbatim, so their volatility
205
+ * and defaults stay owned by {@link HUMAN_PROFILE_SETTINGS_SCHEMA}, plus the
206
+ * gate and the judge. A patch layer that targets this row replaces its whole
207
+ * config, so the parts are declared together here and nowhere else.
208
+ */
209
+ export const TEAM_HOST_ROW_SETTINGS_SCHEMA = z.object({
210
+ ...HUMAN_PROFILE_SETTINGS_SCHEMA.dict,
211
+ gate: TEAM_PRESSURE_GATE_SCHEMA,
212
+ jev: TEAM_JUDGE_SETTINGS_SCHEMA,
213
+ });
176
214
  let AgentTeam = (() => {
177
215
  let _classSuper = TypertRemoteService;
178
216
  let _instanceExtraInitializers = [];
@@ -197,6 +235,7 @@ let AgentTeam = (() => {
197
235
  let _sendMessage_decorators;
198
236
  let _putAttachment_decorators;
199
237
  let _getAttachment_decorators;
238
+ let _contextJudgeForClient_decorators;
200
239
  let _environmentForClient_decorators;
201
240
  let _humanProfileForClient_decorators;
202
241
  let _putHumanAvatar_decorators;
@@ -234,6 +273,7 @@ let AgentTeam = (() => {
234
273
  _sendMessage_decorators = [Remote('sendMessage')];
235
274
  _putAttachment_decorators = [Remote('putAttachment')];
236
275
  _getAttachment_decorators = [Remote('getAttachment')];
276
+ _contextJudgeForClient_decorators = [Remote('contextJudge')];
237
277
  _environmentForClient_decorators = [Remote('environment')];
238
278
  _humanProfileForClient_decorators = [Remote('humanProfile')];
239
279
  _putHumanAvatar_decorators = [Remote('putHumanAvatar')];
@@ -268,6 +308,7 @@ let AgentTeam = (() => {
268
308
  __esDecorate(this, null, _sendMessage_decorators, { kind: "method", name: "sendMessage", static: false, private: false, access: { has: obj => "sendMessage" in obj, get: obj => obj.sendMessage }, metadata: _metadata }, null, _instanceExtraInitializers);
269
309
  __esDecorate(this, null, _putAttachment_decorators, { kind: "method", name: "putAttachment", static: false, private: false, access: { has: obj => "putAttachment" in obj, get: obj => obj.putAttachment }, metadata: _metadata }, null, _instanceExtraInitializers);
270
310
  __esDecorate(this, null, _getAttachment_decorators, { kind: "method", name: "getAttachment", static: false, private: false, access: { has: obj => "getAttachment" in obj, get: obj => obj.getAttachment }, metadata: _metadata }, null, _instanceExtraInitializers);
311
+ __esDecorate(this, null, _contextJudgeForClient_decorators, { kind: "method", name: "contextJudgeForClient", static: false, private: false, access: { has: obj => "contextJudgeForClient" in obj, get: obj => obj.contextJudgeForClient }, metadata: _metadata }, null, _instanceExtraInitializers);
271
312
  __esDecorate(this, null, _environmentForClient_decorators, { kind: "method", name: "environmentForClient", static: false, private: false, access: { has: obj => "environmentForClient" in obj, get: obj => obj.environmentForClient }, metadata: _metadata }, null, _instanceExtraInitializers);
272
313
  __esDecorate(this, null, _humanProfileForClient_decorators, { kind: "method", name: "humanProfileForClient", static: false, private: false, access: { has: obj => "humanProfileForClient" in obj, get: obj => obj.humanProfileForClient }, metadata: _metadata }, null, _instanceExtraInitializers);
273
314
  __esDecorate(this, null, _putHumanAvatar_decorators, { kind: "method", name: "putHumanAvatar", static: false, private: false, access: { has: obj => "putHumanAvatar" in obj, get: obj => obj.putHumanAvatar }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -284,7 +325,7 @@ let AgentTeam = (() => {
284
325
  if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
285
326
  }
286
327
  config = __runInitializers(this, _instanceExtraInitializers);
287
- static Config = HUMAN_PROFILE_SETTINGS_SCHEMA;
328
+ static Config = TEAM_HOST_ROW_SETTINGS_SCHEMA;
288
329
  static inject = [
289
330
  'storageDomain',
290
331
  'workspaceRegistry',
@@ -333,6 +374,16 @@ let AgentTeam = (() => {
333
374
  */
334
375
  memberFailures = new Map();
335
376
  pressurePolicy;
377
+ /**
378
+ * The gate thresholds the pressure policy reads, and the only copy of them:
379
+ * refreshed in place from the row's volatile config, never replaced, so the
380
+ * engine's per-step read sees a settings edit without a remount. It starts at
381
+ * Team's own judge budget and the constructor's refresh replaces that with the
382
+ * row's, so the budget is never absent at any point a step could read it.
383
+ */
384
+ gate = { judgeTimeoutMs: TEAM_JUDGE_TIMEOUT_DEFAULT_MS };
385
+ /** The long-gap gate's judge: this Host's own `jev` mount, reconciled with the row. */
386
+ contextJudge;
336
387
  notifiedInbox = new Map();
337
388
  attachmentGcTimer;
338
389
  /**
@@ -398,21 +449,38 @@ let AgentTeam = (() => {
398
449
  constructor(ctx, config) {
399
450
  super(ctx, 'agentTeam');
400
451
  this.config = config;
401
- this.pressurePolicy = new PressurePolicyCoordinator({
452
+ this.refreshGate();
453
+ this.contextJudge = new TeamContextJudge(this.ctx, {
454
+ config: () => this.judgeConfig(),
455
+ deadlineMs: () => this.gate.judgeTimeoutMs ?? TEAM_JUDGE_TIMEOUT_DEFAULT_MS,
456
+ });
457
+ this.pressurePolicy = new TeamPressurePolicy({
402
458
  agentForMember: memberId => this.handles.get(memberId)?.agent,
403
- memberForAgent: agent => {
404
- const member = this.memberForAgent(agent);
405
- return member === undefined ? undefined : { memberId: member.memberId, sessionId: agent.id };
406
- },
407
- compactionForAgent: agent => this.ctx.agentPresets.serviceFor(agent, 'compaction'),
408
459
  limitsForAgent: agent => this.routeLimitsForAgent(agent),
460
+ compactionForAgent: agent => {
461
+ const engine = this.compactionForAgent(agent);
462
+ return engine === undefined ? undefined : { reduce: (reason, signal) => engine.compactIfNeeded(agent, reason, signal) };
463
+ },
409
464
  activeClaimLabels: memberId => this.activeClaimLabels(memberId),
410
465
  runningJobLabels: memberId => this.runningJobLabels(memberId),
411
- failed: (memberId, _sessionId, diagnostic) => {
466
+ // Deployment policy: the row's own gate config, whose omitted fields the
467
+ // engine answers with its own defaults. This object is this Host's one
468
+ // copy, refreshed in place on every settings write, so an edit reaches the
469
+ // next step judged instead of the next mount.
470
+ gate: this.gate,
471
+ /**
472
+ * The relatedness judge of the long-gap gate: the service this Host
473
+ * mounts from its own row's key, or one a deployment mounted itself. No
474
+ * key and no other mount leaves the gate off — a missing judge is a
475
+ * deployment choice, never a failure — and the read is lazy, so a key
476
+ * written after this Host mounted still answers on the next step.
477
+ */
478
+ judgeForAgent: () => this.contextJudge.judge(),
479
+ failed: (memberId, diagnostic) => {
412
480
  this.setMemberFailure(memberId, 'compaction', diagnostic);
413
481
  this.emitAutoCompactionChanged(memberId);
414
482
  },
415
- log: message => { this.ctx.logger.warn(`agent-team: ${message}`); },
483
+ log: (message, memberId) => { this.ctx.logger.warn(`agent-team: ${message} (member ${this.memberLabel(memberId)})`); },
416
484
  });
417
485
  // Human profile: this Host row's own Config (name + avatarRef, both
418
486
  // volatile), so the settings service derives its form from the schema and
@@ -425,7 +493,41 @@ let AgentTeam = (() => {
425
493
  settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, this.ctx.fiber));
426
494
  this.adoptLegacyHumanProfile();
427
495
  });
428
- this.ctx.on('loader/volatile-update', () => { this.syncHumanHandle(); });
496
+ this.ctx.on('loader/volatile-update', () => {
497
+ this.syncHumanHandle();
498
+ // The whole row is volatile, so every write the settings surface makes is
499
+ // one reconciliation. The gate comes first: the judge's own budget is
500
+ // derived from the gate's deadline, so a write that raises both must not
501
+ // be reconciled against the deadline it replaced.
502
+ this.refreshGate();
503
+ void this.contextJudge.sync().catch(error => {
504
+ this.ctx.logger.warn('agent-team: the relatedness judge could not be reconciled: %s', error instanceof Error ? error.message : String(error));
505
+ });
506
+ });
507
+ }
508
+ /** The Host row's judge settings, as one plain config for the mount. */
509
+ judgeConfig() {
510
+ return {
511
+ apiKey: this.config.jev.apiKey.get(),
512
+ apiKeyEnv: this.config.jev.apiKeyEnv.get(),
513
+ model: this.config.jev.model.get(),
514
+ apiBase: this.config.jev.apiBase.get(),
515
+ };
516
+ }
517
+ /**
518
+ * Copy the row's gate thresholds into the one gate object the pressure policy
519
+ * holds. Called once at construction and again on every volatile update, so
520
+ * the policy always reads the thresholds the settings document states.
521
+ *
522
+ * Two fields pass through as the row states them, and the engine answers an
523
+ * omitted one with its own default. The judge's budget does not: a judge is
524
+ * reached over a network, so Team answers for that number itself rather than
525
+ * making every deployment discover the engine's local-call default.
526
+ */
527
+ refreshGate() {
528
+ this.gate.tokens = this.config.gate.tokens.get();
529
+ this.gate.idleMs = this.config.gate.idleMs.get();
530
+ this.gate.judgeTimeoutMs = this.config.gate.judgeTimeoutMs.get() ?? TEAM_JUDGE_TIMEOUT_DEFAULT_MS;
429
531
  }
430
532
  /** Current human display name; the single source for team_view and @ matching. */
431
533
  humanHandle() {
@@ -536,6 +638,10 @@ let AgentTeam = (() => {
536
638
  }
537
639
  /** Open the durable ledger and restore every enabled Member independently. */
538
640
  async [Service.init]() {
641
+ // The judge is mounted from the row's own key before the first step can be
642
+ // judged: a deployment that states one gets the gate from its first turn,
643
+ // and one that states none keeps the gate off without a failure.
644
+ await this.contextJudge.sync();
539
645
  // The continuity projection registers once per Host: the framework keeps
540
646
  // one unit per projection key and drives it for every Session, so Team's
541
647
  // fold is session-agnostic and the state carries the identity it folds.
@@ -548,10 +654,10 @@ let AgentTeam = (() => {
548
654
  return;
549
655
  const message = error instanceof Error ? error.message : String(error);
550
656
  this.setMemberFailure(member.memberId, 'runtime', message);
551
- const kind = classifyRecoverableError(message);
657
+ const kind = classifyRecoverableError(error);
552
658
  if (kind !== undefined)
553
659
  this.ctx.logger.warn(`agent-team: member '${member.handle}' hit a recoverable ${kind} error; recording a consecutive error occurrence`);
554
- this.recovery.onError(member.memberId, message);
660
+ this.recovery.onError(member.memberId, error);
555
661
  this.emitMemberPresenceChanged(member);
556
662
  });
557
663
  this.ctx.on('agent/status', ({ agent, status }) => {
@@ -601,7 +707,7 @@ let AgentTeam = (() => {
601
707
  // A successful assistant response ends any open provider-overflow
602
708
  // recovery sequence for this Member.
603
709
  if (event.type === 'assistant/message')
604
- this.pressurePolicy.onAssistantMessage(handle.agent);
710
+ this.pressurePolicy.onAssistantMessage(memberId);
605
711
  // Context management reacts only after a successful durable tool/result;
606
712
  // the projection (not this listener) decides what that means.
607
713
  this.contextManagement.onSessionEvent(memberId, handle.agent, event);
@@ -641,10 +747,35 @@ let AgentTeam = (() => {
641
747
  const persistedSessions = new Set((await this.persistedSessionHeaders()).map(snapshot => snapshot.header.id));
642
748
  await this.sweepWorkspaceParticipations(ledger);
643
749
  for (const member of ledger.listMembers()) {
644
- if (member.state === 'enabled')
645
- await this.activateMember(member, undefined, persistedSessions);
646
- else if (member.state === 'inactive')
647
- await this.memberRuntime.cleanupRemovedMember(member);
750
+ if (member.state === 'enabled') {
751
+ // A renewal or rollover that committed but never finished leaves no
752
+ // live handle to reveal it: startup derives the outstanding effects
753
+ // from the ledger and the registry's archive set. A Workspace fault
754
+ // must not keep the Member from running on its recorded binding, so
755
+ // the ordinary activation path still runs when the retire half cannot
756
+ // be finished here.
757
+ try {
758
+ await this.finishMemberTransition(member.memberId, { knownSessions: persistedSessions });
759
+ }
760
+ catch (error) {
761
+ this.ctx.logger.warn(`agent-team: could not finish the recorded Session transition for member '${member.handle}': ${error instanceof Error ? error.message : String(error)}`);
762
+ }
763
+ if (!this.handles.has(member.memberId))
764
+ await this.activateMember(member, undefined, persistedSessions);
765
+ }
766
+ else {
767
+ // Every other durable state converges through the same effect table
768
+ // its operation runs after the commit, so a crash or a failed call
769
+ // between commit and effect is finished here. One Member's fault
770
+ // must not fail the boot: it is logged and retried at the next
771
+ // startup, where the call that failed may have healed.
772
+ try {
773
+ await this.convergeMemberEffects(member);
774
+ }
775
+ catch (error) {
776
+ this.ctx.logger.warn(`agent-team: could not converge member '${member.handle}' to its '${member.state}' state: ${error instanceof Error ? error.message : String(error)}`);
777
+ }
778
+ }
648
779
  }
649
780
  }
650
781
  /**
@@ -834,13 +965,13 @@ let AgentTeam = (() => {
834
965
  return Object.freeze({ receipt: result.value.receipt, status: this.memberStatus(stored), workspaceIds: ledger.workspacesOf(stored.memberId) });
835
966
  });
836
967
  }
837
- /** Commit suspended intent, then wait for the owned AgentHandle to become quiescent. */
968
+ /** Commit suspended intent, then run the suspended row of the effect table: the owned AgentHandle is disposed, the Session stays. */
838
969
  async suspendMember(request) {
839
970
  return this.enqueueLifecycle(async () => {
840
971
  const result = await this.requireLedger().suspendMember({ ...request, actor: agentTeamHumanActor() });
841
972
  if (result.committed)
842
973
  this.emitCommitted(result.value.receipt);
843
- await this.disposeMemberSession(request.memberId, result.value.member);
974
+ await this.convergeMemberEffects(result.value.member);
844
975
  return Object.freeze({ receipt: result.value.receipt, status: this.memberStatus(result.value.member) });
845
976
  });
846
977
  }
@@ -866,7 +997,9 @@ let AgentTeam = (() => {
866
997
  * continuation prompt into its live session, rebuild it after an orphaned
867
998
  * preset composition, or re-run activation when no live session exists.
868
999
  * Runtime-only — no ledger operation, no suspend. Taking over manually also
869
- * cancels any pending automatic recovery episode.
1000
+ * cancels any pending automatic recovery episode. A non-enabled Member
1001
+ * still holding a live handle is an interrupted suspend/archive/remove,
1002
+ * not a stalled one; recovery finishes that cleanup instead of nudging it.
870
1003
  */
871
1004
  async recoverMember(request) {
872
1005
  this.requireAccepting();
@@ -874,9 +1007,36 @@ let AgentTeam = (() => {
874
1007
  if (member === undefined || !this.requireLedger().participatesIn(request.memberId, request.workspaceId))
875
1008
  throw new Error(`unknown Member '${request.memberId}' in workspace '${request.workspaceId}'`);
876
1009
  this.recovery.stopTracking(request.memberId);
1010
+ const handle = this.handles.get(request.memberId);
1011
+ // A committed renewal or rollover whose retire or activation never finished
1012
+ // is what leaves a Member with no live generation on its binding, or with
1013
+ // the retired generation still live: this explicit recovery path finishes
1014
+ // that recorded transition rather than restarting a Session the ledger has
1015
+ // already moved off. Every other stranded case keeps its old behavior.
1016
+ const stranded = handle === undefined || handle.agent.id !== member.sessionId;
1017
+ if (stranded && member.state === 'enabled' && this.requireLedger().lastTransitionForMember(request.memberId) !== undefined) {
1018
+ this.ctx.logger.info(`agent-team: finishing the recorded Session transition for member '${member.handle}'`);
1019
+ const settled = await this.enqueueLifecycle(async () => {
1020
+ await this.finishMemberTransition(request.memberId);
1021
+ return this.requireLedger().getMember(request.memberId) ?? member;
1022
+ });
1023
+ return Object.freeze({ status: this.memberStatus(settled) });
1024
+ }
1025
+ // A live handle on a non-enabled Member is the residue of a suspend,
1026
+ // archive, or removal whose disposal failed after its commit: the
1027
+ // durable state already says no live generation, so explicit recovery
1028
+ // re-runs that state's effects rather than feeding the stray generation
1029
+ // a resume prompt or rebuilding it as if it were enabled.
1030
+ if (member.state !== 'enabled' && handle !== undefined) {
1031
+ this.ctx.logger.info(`agent-team: finishing the interrupted ${member.state} cleanup for member '${member.handle}'`);
1032
+ const settled = await this.enqueueLifecycle(async () => {
1033
+ await this.convergeMemberEffects(member);
1034
+ return this.requireLedger().getMember(request.memberId) ?? member;
1035
+ });
1036
+ return Object.freeze({ status: this.memberStatus(settled) });
1037
+ }
877
1038
  // An orphaned composition cannot be steered: its tools are gone, so a
878
1039
  // continuation prompt reaches an inert Member. Rebuild the Agent in place.
879
- const handle = this.handles.get(request.memberId);
880
1040
  if (handle !== undefined && this.ctx.agentPresets.composedPreset(handle.agent.ctx) === undefined) {
881
1041
  this.ctx.logger.info(`agent-team: rebuilding member '${member.handle}' after its preset composition was orphaned by a reload`);
882
1042
  await this.reactivateMember(request.memberId);
@@ -920,27 +1080,50 @@ let AgentTeam = (() => {
920
1080
  throw new Error(`unknown Member '${request.memberId}' in workspace '${request.workspaceId}'`);
921
1081
  if (stored.state !== 'enabled')
922
1082
  throw new Error(`Agent Member '${stored.handle}' is ${stored.state}; only enabled Members can start from a new context`);
923
- const active = this.handles.get(request.memberId);
924
- if (active === undefined)
925
- throw new Error(`Agent Member '${stored.handle}' has no active session to clear`);
926
- if (this.runningAgents.has(active.agent.id))
927
- throw new Error(`Agent Member '${stored.handle}' is still running; wait for the current turn to end before starting from a new context`);
928
- const previousSessionId = stored.sessionId;
929
1083
  // The fresh id derives from the requestId, so a retried identical
930
1084
  // request mints the same id and the ledger dedupes it instead of
931
1085
  // colliding; the format matches addMember's `agent-team-<uuid>`.
932
1086
  const sessionId = SessionId(`agent-team-${request.requestId}`);
1087
+ // A renewal this request already committed IS the desired binding: its
1088
+ // retire or activation effects may still be outstanding, so finish them
1089
+ // first, then answer with the recorded result either way. A repeat never
1090
+ // commits a second binding, and never reports an effect that did not run
1091
+ // as though it had — the status below carries that truth.
1092
+ if (this.requireLedger().hasCommitted(request.requestId)) {
1093
+ try {
1094
+ await this.finishMemberTransition(request.memberId);
1095
+ }
1096
+ catch (error) {
1097
+ this.ctx.logger.warn(`agent-team: could not finish the recorded context renewal for member '${stored.handle}': ${error instanceof Error ? error.message : String(error)}`);
1098
+ }
1099
+ const recorded = await this.requireLedger().renewMemberSession({ ...request, sessionId, actor: agentTeamHumanActor() });
1100
+ const current = this.requireLedger().getMember(request.memberId) ?? stored;
1101
+ return Object.freeze({ receipt: recorded.value.receipt, status: this.memberStatus(current) });
1102
+ }
1103
+ const active = this.handles.get(request.memberId);
1104
+ if (active === undefined) {
1105
+ // With no live generation to retire, an earlier transition may be what
1106
+ // left the Member here; finishing it is what lets this request run at
1107
+ // all. With none, the ordinary no-session refusal below stands.
1108
+ await this.finishMemberTransition(request.memberId);
1109
+ }
1110
+ const retiring = this.handles.get(request.memberId);
1111
+ if (retiring === undefined)
1112
+ throw new Error(`Agent Member '${stored.handle}' has no active session to clear`);
1113
+ if (this.runningAgents.has(retiring.agent.id))
1114
+ throw new Error(`Agent Member '${stored.handle}' is still running; wait for the current turn to end before starting from a new context`);
933
1115
  const result = await this.requireLedger().renewMemberSession({ ...request, sessionId, actor: agentTeamHumanActor() });
934
- if (result.committed)
935
- this.emitCommitted(result.value.receipt);
936
- else {
937
- // A retried identical request already renewed this Member; report the
938
- // recorded outcome without another dispose/reactivate cycle.
939
- return Object.freeze({ receipt: result.value.receipt, status: this.memberStatus(result.value.member) });
1116
+ if (!result.committed) {
1117
+ // Another caller recorded this requestId between the check above and
1118
+ // the write; report its recorded outcome as the retry path does.
1119
+ const current = this.requireLedger().getMember(request.memberId) ?? stored;
1120
+ return Object.freeze({ receipt: result.value.receipt, status: this.memberStatus(current) });
940
1121
  }
1122
+ this.emitCommitted(result.value.receipt);
941
1123
  const renewed = result.value.member;
942
- await this.retireMemberGeneration(request.memberId, active, previousSessionId);
943
- await this.activateMember(renewed, undefined, undefined, previousSessionId);
1124
+ // This request retired the previous generation itself, so the activation
1125
+ // is planned rather than recovered: pass the fork parent it just left.
1126
+ await this.finishMemberTransition(renewed.memberId, { forkedFrom: stored.sessionId });
944
1127
  const reactivated = this.handles.get(request.memberId);
945
1128
  if (reactivated === undefined) {
946
1129
  // Reactivation failed; the activation diagnostic carries the reason and
@@ -950,6 +1133,79 @@ let AgentTeam = (() => {
950
1133
  return Object.freeze({ receipt: result.value.receipt, status: this.memberStatus(renewed) });
951
1134
  });
952
1135
  }
1136
+ /**
1137
+ * Finish one Member's committed Session transition, whichever half a failure
1138
+ * interrupted. The ledger already records the binding the Member should run
1139
+ * and the Session it left, so only external effects remain and each is
1140
+ * derived fresh: a generation still live on the old Session is retired, the
1141
+ * retired Session is archived once the registry does not yet report it
1142
+ * archived, and the target activates when no live generation runs it.
1143
+ *
1144
+ * A live handle alone never decides completion — disposal can succeed and
1145
+ * leave the archive half owed — so the registry's own archive set is the
1146
+ * evidence for that step.
1147
+ *
1148
+ * `forkedFrom` marks a PLANNED in-process activation (the caller just
1149
+ * retired that Session in this same request): `activateMember` reads its
1150
+ * absence as a recovered Session that may never have materialized and runs
1151
+ * the handoff-reconstruction path. Startup, retry, and explicit recovery
1152
+ * therefore leave it unset; only the request that committed the transition
1153
+ * supplies it.
1154
+ *
1155
+ * Callers must already hold the lifecycle queue (or run before any other
1156
+ * lifecycle work): this method does not re-enter it.
1157
+ */
1158
+ async finishMemberTransition(memberId, options) {
1159
+ const member = this.requireLedger().getMember(memberId);
1160
+ if (member === undefined || member.state !== 'enabled')
1161
+ return;
1162
+ const transition = this.requireLedger().lastTransitionForMember(memberId);
1163
+ if (transition === undefined)
1164
+ return;
1165
+ const live = this.handles.get(memberId);
1166
+ if (live !== undefined && live.agent.id !== member.sessionId) {
1167
+ await this.retireMemberGeneration(memberId, live, live.agent.id);
1168
+ }
1169
+ if (!this.ctx.workspaceRegistry.archivedSessionIds.includes(transition.previousSessionId)) {
1170
+ await this.ctx.workspaceRegistry.archiveSession(transition.previousSessionId);
1171
+ }
1172
+ if (this.handles.get(memberId) === undefined) {
1173
+ await this.activateMember(member, undefined, options?.knownSessions, options?.forkedFrom);
1174
+ }
1175
+ }
1176
+ /**
1177
+ * Converge one Member's external effects onto its durable lifecycle state
1178
+ * — the single effect table every lifecycle operation runs right after its
1179
+ * commit and startup re-runs for every non-enabled Member:
1180
+ *
1181
+ * - `suspended`: no live handle; the Session and private memory stay.
1182
+ * - `archived`: no live handle; the Session is hidden from grouping
1183
+ * surfaces while its log and private memory stay on disk.
1184
+ * - `inactive`: no live handle; the Session is archived and the private
1185
+ * memory deleted — removeMember's cleanup primitives.
1186
+ *
1187
+ * `enabled` has no entry here: its effects belong to the recorded Session
1188
+ * transition and activation. Every step is derived from the durable state
1189
+ * and safe to repeat — the registry's archive set decides whether the
1190
+ * hide half is owed, disposal tolerates an absent handle, and removal
1191
+ * cleanup force-tolerates an already-cleaned target — so a repeat after a
1192
+ * crash or a failed call runs exactly the owed steps and never a second
1193
+ * durable write. Callers must already hold the lifecycle queue (or run
1194
+ * before any other lifecycle work, as startup does).
1195
+ */
1196
+ async convergeMemberEffects(member) {
1197
+ if (member.state === 'enabled')
1198
+ return;
1199
+ await this.disposeMemberSession(member.memberId, member);
1200
+ if (member.state === 'archived') {
1201
+ if (!this.ctx.workspaceRegistry.archivedSessionIds.includes(member.sessionId)) {
1202
+ await this.ctx.workspaceRegistry.archiveSession(member.sessionId);
1203
+ }
1204
+ }
1205
+ else if (member.state === 'inactive') {
1206
+ await this.memberRuntime.cleanupRemovedMember(member);
1207
+ }
1208
+ }
953
1209
  /**
954
1210
  * Retire one Member's previous generation after its durable binding moved
955
1211
  * onto a new Session id: drop the old handle's transient state, dispose the
@@ -1179,8 +1435,7 @@ let AgentTeam = (() => {
1179
1435
  const result = await this.requireLedger().removeMember({ ...request, actor: agentTeamHumanActor() });
1180
1436
  if (result.committed)
1181
1437
  this.emitCommitted(result.value.receipt);
1182
- await this.disposeMemberSession(request.memberId, result.value.member);
1183
- await this.memberRuntime.cleanupRemovedMember(result.value.member);
1438
+ await this.convergeMemberEffects(result.value.member);
1184
1439
  return result.value;
1185
1440
  });
1186
1441
  }
@@ -1195,8 +1450,7 @@ let AgentTeam = (() => {
1195
1450
  const result = await this.requireLedger().archiveMember({ ...request, actor: agentTeamHumanActor() });
1196
1451
  if (result.committed)
1197
1452
  this.emitCommitted(result.value.receipt);
1198
- await this.disposeMemberSession(request.memberId, result.value.member);
1199
- await this.ctx.workspaceRegistry.archiveSession(result.value.member.sessionId);
1453
+ await this.convergeMemberEffects(result.value.member);
1200
1454
  return result.value;
1201
1455
  });
1202
1456
  }
@@ -1258,15 +1512,18 @@ let AgentTeam = (() => {
1258
1512
  /**
1259
1513
  * Resolve uploaded ids and agent-supplied absolute paths into one attachment
1260
1514
  * metadata list. Paths are all validated before anything is copied, so one
1261
- * rejection leaves the cache untouched and the message uncommitted.
1515
+ * rejection leaves the cache untouched and the message uncommitted. Each copy
1516
+ * takes the id its request derives, so a retry converges on the entry the
1517
+ * first attempt prepared instead of writing an orphan.
1262
1518
  */
1263
1519
  async resolveMessageAttachments(request) {
1264
1520
  const fromPaths = [];
1265
1521
  if (request.attachmentPaths !== undefined && request.attachmentPaths.length > 0) {
1266
1522
  for (const absolutePath of request.attachmentPaths)
1267
1523
  await validatePathAttachment(absolutePath);
1268
- for (const absolutePath of request.attachmentPaths)
1269
- fromPaths.push(Object.freeze(await copyPathAttachment(attachmentsRoot(), absolutePath)));
1524
+ for (const [index, absolutePath] of request.attachmentPaths.entries()) {
1525
+ fromPaths.push(Object.freeze(await copyPathAttachment(attachmentsRoot(), absolutePath, pathAttachmentId(request.requestId, index), request.requestId)));
1526
+ }
1270
1527
  }
1271
1528
  return [...fromPaths, ...await this.prepareAttachments(request.attachments)];
1272
1529
  }
@@ -1294,7 +1551,12 @@ let AgentTeam = (() => {
1294
1551
  const lines = metadata.map(attachment => `[attachment] ${attachmentPayloadPath(attachment.attachmentId, attachment.name)}`);
1295
1552
  return `${trimmed}\n${lines.join('\n')}`;
1296
1553
  }
1297
- /** Upload one composer attachment into the cache; bytes are immutable once written. */
1554
+ /**
1555
+ * Upload one composer attachment into the cache; bytes are immutable once
1556
+ * written. The requestId is the idempotency key: a retried upload derives
1557
+ * the same cache id and replays the original result, while reusing that key
1558
+ * for a different payload is refused as a request collision.
1559
+ */
1298
1560
  async putAttachment(request) {
1299
1561
  this.requireAccepting();
1300
1562
  this.requireWorkspace(request.workspaceId);
@@ -1304,16 +1566,45 @@ let AgentTeam = (() => {
1304
1566
  if (bytes.byteLength > ATTACHMENT_MAX_BYTES)
1305
1567
  throw new Error(`attachment exceeds the ${ATTACHMENT_MAX_BYTES} byte limit`);
1306
1568
  const mediaType = sanitizeMediaType(request.mediaType);
1307
- const attachmentId = newAttachmentId();
1308
- return Object.freeze(await writeAttachment(attachmentsRoot(), attachmentId, request.name, mediaType, bytes));
1569
+ return Object.freeze(await writeRequestScopedAttachment(attachmentsRoot(), request.requestId, request.name, mediaType, bytes));
1309
1570
  }
1310
- /** Read one cached attachment back for client display; gone entries throw and the UI degrades to a chip. */
1571
+ /**
1572
+ * Read one cached attachment back for client display. A gone entry is the
1573
+ * one expected failure on this path, so it carries the stable
1574
+ * `team/attachment-not-found` code the Client branches on; anything else
1575
+ * (unreadable metadata, filesystem trouble) stays an unknown Host error.
1576
+ */
1311
1577
  async getAttachment(request) {
1312
1578
  const stored = await readAttachment(attachmentsRoot(), request.attachmentId);
1313
1579
  if (stored === undefined)
1314
- throw new Error(`attachment '${request.attachmentId}' is no longer cached`);
1580
+ throw new RemoteError('team/attachment-not-found', `attachment '${request.attachmentId}' is no longer cached`, { attachmentId: request.attachmentId });
1315
1581
  return Object.freeze({ name: stored.name, mediaType: stored.mediaType, byteSize: stored.byteSize, bytesBase64: stored.bytes.toString('base64') });
1316
1582
  }
1583
+ /**
1584
+ * The long-gap gate's judge, as the Team settings surface states it: whether
1585
+ * a judge is reachable, why not when none is, and the thresholds in force.
1586
+ * The judge's own fields (key presence, endpoint, model) ride the settings
1587
+ * document this row already publishes, so nothing here restates them; the
1588
+ * gate's thresholds are row configuration the settings document deliberately
1589
+ * omits, which is why the Host resolves an omitted field here rather than
1590
+ * letting a second copy of those numbers exist. Two of the three fall back to
1591
+ * the engine's own default; the judge's budget falls back to Team's, because
1592
+ * that is the number the gate itself is given.
1593
+ */
1594
+ contextJudgeForClient(_request) {
1595
+ const status = this.contextJudge.status();
1596
+ const gate = this.gate;
1597
+ return Object.freeze({
1598
+ ...status,
1599
+ gate: Object.freeze({
1600
+ tokens: gate.tokens ?? DEFAULT_GATE_TOKENS,
1601
+ idleMs: gate.idleMs ?? DEFAULT_GATE_IDLE_MS,
1602
+ // No fallback here: this number is the one the gate is given, so a
1603
+ // second default could only ever disagree with the gate in force.
1604
+ judgeTimeoutMs: gate.judgeTimeoutMs,
1605
+ }),
1606
+ });
1607
+ }
1317
1608
  /**
1318
1609
  * Human profile read for the settings page and footnote: name + avatar
1319
1610
  * reference from this Host row's live Config plus version facts. Human-scoped
@@ -1541,8 +1832,8 @@ let AgentTeam = (() => {
1541
1832
  const taskBoundaryThreshold = Math.min(ACCEPT_TASK_BOUNDARY_THRESHOLD, limits.handoffAt);
1542
1833
  if (limits.usageTokens >= limits.handoffAt) {
1543
1834
  return Object.freeze({ usageTokens: limits.usageTokens, taskBoundaryThreshold, handoffAt: limits.handoffAt, hardLimit: limits.hardLimit,
1544
- action: 'handoff-now',
1545
- guidance: 'You are at or above the handoff budget. Finish the current atomic action and unsettled evidence, then call context_rollover with a fresh handoff now.' });
1835
+ action: 'compact-now',
1836
+ guidance: 'You are at or above the handoff budget. Finish the current atomic action and unsettled evidence, then call context_compact with a summary you write yourself — that shortens this generation in place, keeps your recent work verbatim, and is the default. Roll over only when the work has turned a page.' });
1546
1837
  }
1547
1838
  if (limits.usageTokens >= taskBoundaryThreshold) {
1548
1839
  return Object.freeze({ usageTokens: limits.usageTokens, taskBoundaryThreshold, handoffAt: limits.handoffAt, hardLimit: limits.hardLimit,
@@ -1972,6 +2263,16 @@ let AgentTeam = (() => {
1972
2263
  this.ctx.logger.info(`agent-team: redelivered ${redelivered} carried input message(s) for member '${this.memberLabel(member.memberId)}' after the rollover crash recovery`);
1973
2264
  return redelivered;
1974
2265
  }
2266
+ /**
2267
+ * The compaction capability in one Member Agent's preset scope, or undefined
2268
+ * when its composition mounts none. Both the pressure policy (the hard limit)
2269
+ * and the engine's `context_compact` tool ask through here, so which realm
2270
+ * holds the engine is stated once: a preset revision publishes services
2271
+ * behind its own isolate, which `agent.ctx.get` cannot see from outside.
2272
+ */
2273
+ compactionForAgent(agent) {
2274
+ return this.ctx.agentPresets.serviceFor(agent, 'compaction');
2275
+ }
1975
2276
  /**
1976
2277
  * Agent-only checkpoint request validation: the tool calls this inside its
1977
2278
  * own running turn. Like `context_rollover`, the tool performs no side effect —
@@ -2007,10 +2308,10 @@ let AgentTeam = (() => {
2007
2308
  async contextTimelineForAgent(agent, request) {
2008
2309
  const member = this.memberForAgent(agent);
2009
2310
  if (member === undefined || member.state !== 'enabled')
2010
- throw new Error('context_timeline requires an active Team Member');
2311
+ throw new Error('context_status requires an active Team Member');
2011
2312
  const limit = request.limit === undefined ? DEFAULT_TIMELINE_LIMIT : Math.trunc(request.limit);
2012
2313
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_TIMELINE_LIMIT)
2013
- throw new Error(`context_timeline limit must be between 1 and ${MAX_TIMELINE_LIMIT}`);
2314
+ throw new Error(`context_status limit must be between 1 and ${MAX_TIMELINE_LIMIT}`);
2014
2315
  const meter = agent.ctx.get('tokenMeter');
2015
2316
  const measurement = meter?.measure(agent.session);
2016
2317
  const usageTokens = measurement?.totalTokens ?? 0;
@@ -2225,7 +2526,7 @@ let AgentTeam = (() => {
2225
2526
  if (request.checkpointRef === undefined)
2226
2527
  return { mode: 'fresh' };
2227
2528
  if (!/^(context-checkpoint-[0-9a-f]{64}|team-boundary-[0-9a-f]{64})$/.test(request.checkpointRef))
2228
- throw new Error('checkpointRef must be an opaque ref exactly as returned by context_timeline');
2529
+ throw new Error('checkpointRef must be an opaque ref exactly as returned by context_status');
2229
2530
  // Full current-state prevalidation through the ONE resolver the swap
2230
2531
  // itself uses: a ref that is fabricated, unattributable, nonshrinking,
2231
2532
  // unmeasurable, over-budget, or blocked by multiple active Claims
@@ -2278,25 +2579,69 @@ let AgentTeam = (() => {
2278
2579
  }
2279
2580
  /** Shared Task-creation commit: resolve uploads into metadata lines and append through the ledger. */
2280
2581
  async sendMessageAs(actor, request) {
2281
- const metadata = await this.resolveMessageAttachments(request);
2282
- const result = await this.requireLedger().sendMessage({
2283
- ...request, body: this.appendAttachmentLines(request.body, metadata),
2284
- ...(metadata.length === 0 ? {} : { resolvedAttachments: metadata }),
2285
- actor,
2286
- });
2287
- this.emitCommittedOutcome(result);
2288
- return result.value;
2582
+ try {
2583
+ const metadata = await this.resolveMessageAttachments(request);
2584
+ const result = await this.requireLedger().sendMessage({
2585
+ ...request, body: this.appendAttachmentLines(request.body, metadata),
2586
+ ...(metadata.length === 0 ? {} : { resolvedAttachments: metadata }),
2587
+ actor,
2588
+ });
2589
+ this.emitCommittedOutcome(result);
2590
+ return result.value;
2591
+ }
2592
+ catch (error) {
2593
+ await this.dropUnreferencedPrepared(request, error);
2594
+ throw error;
2595
+ }
2289
2596
  }
2290
2597
  /** Shared existing-Thread reply commit: same upload resolution and outcome emission. */
2291
2598
  async replyAs(actor, request) {
2292
- const metadata = await this.resolveMessageAttachments(request);
2293
- const result = await this.requireLedger().reply({
2294
- ...request, body: this.appendAttachmentLines(request.body, metadata),
2295
- ...(metadata.length === 0 ? {} : { resolvedAttachments: metadata }),
2296
- actor,
2297
- });
2298
- this.emitCommittedOutcome(result);
2299
- return result.value;
2599
+ try {
2600
+ const metadata = await this.resolveMessageAttachments(request);
2601
+ const result = await this.requireLedger().reply({
2602
+ ...request, body: this.appendAttachmentLines(request.body, metadata),
2603
+ ...(metadata.length === 0 ? {} : { resolvedAttachments: metadata }),
2604
+ actor,
2605
+ });
2606
+ this.emitCommittedOutcome(result);
2607
+ return result.value;
2608
+ }
2609
+ catch (error) {
2610
+ await this.dropUnreferencedPrepared(request, error);
2611
+ throw error;
2612
+ }
2613
+ }
2614
+ /**
2615
+ * After a failed message or reply attempt, remove the cache entries this
2616
+ * attempt could have prepared — the request-stable path copies — unless a
2617
+ * committed Message references them. The ledger's own reference set is the
2618
+ * guard, so a post-commit failure or a colliding retry can never delete bytes
2619
+ * a stored Message still points at; uploads are untouched, because an
2620
+ * unreferenced upload is a legitimate state the GC owns. Cleanup failures are
2621
+ * logged and never mask the original error.
2622
+ */
2623
+ async dropUnreferencedPrepared(request, cause) {
2624
+ const prepared = (request.attachmentPaths ?? []).map((_, index) => pathAttachmentId(request.requestId, index));
2625
+ if (prepared.length === 0)
2626
+ return;
2627
+ let referenced;
2628
+ try {
2629
+ referenced = this.requireLedger().referencedAttachmentIds();
2630
+ }
2631
+ catch (failure) {
2632
+ this.ctx.logger.warn(`agent-team: could not consult the attachment reference set after a failed message attempt (${String(cause)}): ${String(failure)}`);
2633
+ return;
2634
+ }
2635
+ for (const attachmentId of prepared) {
2636
+ if (referenced.has(attachmentId))
2637
+ continue;
2638
+ try {
2639
+ await removeAttachment(attachmentsRoot(), attachmentId);
2640
+ }
2641
+ catch (failure) {
2642
+ this.ctx.logger.warn(`agent-team: could not drop unreferenced prepared attachment '${attachmentId}' after a failed message attempt: ${String(failure)}`);
2643
+ }
2644
+ }
2300
2645
  }
2301
2646
  /** Fence one Human Remote call: accepting Host, known Workspace, Human actor. */
2302
2647
  humanCall(workspaceId) {
@@ -2417,12 +2762,26 @@ let AgentTeam = (() => {
2417
2762
  this.contextManagement.captureClaimedInput(agent, messages);
2418
2763
  return { kind: 'reject' };
2419
2764
  }
2420
- // Team pressure policy rides the same pre-step seam after the
2765
+ // The engine's pressure policy rides the same pre-step seam after the
2421
2766
  // admission gate: the handoff-budget notice steers into the running
2422
- // turn, and the hard limit forces compaction before the request is
2767
+ // turn, the hard limit forces a reduction before the request is
2423
2768
  // forwarded — failing closed blocks the step instead of submitting
2424
- // over the Team limit. Missing route capacity is an explicit reject.
2425
- const pressure = await this.pressurePolicy.onPreStep(agent, signal);
2769
+ // over the limit — and a step arriving into a large context after a
2770
+ // long gap may be held instead of admitted.
2771
+ const member = this.memberForAgent(agent);
2772
+ const pressure = member === undefined
2773
+ ? { kind: 'continue' }
2774
+ : await this.pressurePolicy.onPreStep(member.memberId, messages, signal);
2775
+ if (pressure.kind === 'hold') {
2776
+ // The gate kept the messages this step had claimed, and its one
2777
+ // rollover instruction is already steered into their place. Arming
2778
+ // the hold and keeping those messages are one call; the coordinator
2779
+ // owns both exits from here — the input comes back once the driver
2780
+ // converges, or rides into the next generation if the rollover
2781
+ // lands — so this turn ends with nothing further to do.
2782
+ this.contextManagement.holdClaimedInput(agent, messages);
2783
+ return { kind: 'reject' };
2784
+ }
2426
2785
  if (pressure.kind === 'reject')
2427
2786
  return { kind: 'reject' };
2428
2787
  const decision = await next();
@@ -2431,10 +2790,11 @@ let AgentTeam = (() => {
2431
2790
  this.contextManagement.captureClaimedInput(agent, messages);
2432
2791
  return { kind: 'reject' };
2433
2792
  });
2434
- // Provider context-overflow recovery: one bounded compact-and-retry
2435
- // sequence per failure chain through the Team-owned policy.
2793
+ // Provider context-overflow recovery: one bounded reduce-and-retry
2794
+ // sequence per failure chain through the engine's policy.
2436
2795
  agentCtx.on('agent/request-error', async ({ agent, failure, signal }, next) => {
2437
- const retry = await this.pressurePolicy.onRequestError(agent, failure, signal);
2796
+ const member = this.memberForAgent(agent);
2797
+ const retry = member !== undefined && await this.pressurePolicy.onRequestError(member.memberId, failure, signal);
2438
2798
  if (retry)
2439
2799
  return { kind: 'retry' };
2440
2800
  return next();
@@ -2735,42 +3095,67 @@ let AgentTeam = (() => {
2735
3095
  throw new Error('agent-team service is shutting down');
2736
3096
  }
2737
3097
  emitCommitted(receipt) {
2738
- this.ctx.emit('agent-team/committed', { receipt });
3098
+ // Everything below runs after the ledger append, so nothing here may turn
3099
+ // a committed operation into a failed Remote call. Each effect that runs
3100
+ // third-party code — Cordis listeners, change waiters, agent wakes — goes
3101
+ // through `afterCommit`; the ordering itself is unchanged: listeners,
3102
+ // then Client invalidation, then the participation notice, then wakes.
3103
+ this.afterCommit('commit listener dispatch', () => this.ctx.emit('agent-team/committed', { receipt }));
2739
3104
  const operation = this.ledger?.getOperation(receipt.operationId);
2740
3105
  if (operation === undefined) {
2741
- this.emitChanged();
3106
+ this.afterCommit('change invalidation', () => this.emitChanged());
2742
3107
  return;
2743
3108
  }
2744
3109
  const ledger = this.requireLedger();
2745
- this.emitChanged(ledger.changeScopesOf(operation));
3110
+ this.afterCommit('change invalidation', () => this.emitChanged(ledger.changeScopesOf(operation)));
2746
3111
  if (operation.kind === 'team/member-workspace-joined' || operation.kind === 'team/member-workspace-left') {
2747
- const agent = this.handles.get(operation.data.memberId)?.agent;
2748
- if (agent !== undefined) {
3112
+ this.afterCommit('participation notice', () => {
3113
+ const agent = this.handles.get(operation.data.memberId)?.agent;
3114
+ if (agent === undefined)
3115
+ return;
2749
3116
  const path = this.ctx.workspaceRegistry.get(operation.data.workspaceId)?.path;
2750
3117
  const text = `Team participation changed: you have ${operation.kind === 'team/member-workspace-joined' ? 'joined' : 'left'} Workspace ${operation.data.workspaceId}${path === undefined ? ' (path unavailable)' : ` (${JSON.stringify(path)})`}.\nCurrent Workspace ids: ${ledger.workspacesOf(operation.data.memberId).join(', ')}. This replaces earlier participation information. Your Session and cwd have not moved.`;
2751
3118
  const notice = createUserMessage({ content: [{ type: 'text', text }],
2752
3119
  source: { kind: AGENT_TEAM_PLUGIN_ID, form: 'notice', summary: 'Team Workspace participation changed' } });
2753
- try {
2754
- if (agent.status === 'idle' || agent.inbox.nextTurn.some(message => message.source.kind === 'user'))
2755
- agent.followup(notice);
2756
- else
2757
- agent.steer(notice);
2758
- }
2759
- catch (error) {
2760
- // Notification delivery cannot roll back a committed participation.
2761
- this.ctx.logger.warn(`agent-team: participation notice not delivered: ${error instanceof Error ? error.message : String(error)}`);
2762
- }
2763
- }
3120
+ // Delivered from durable facts; a failed wake cannot roll back the
3121
+ // committed participation — the next notice rederives it.
3122
+ if (agent.status === 'idle' || agent.inbox.nextTurn.some(message => message.source.kind === 'user'))
3123
+ agent.followup(notice);
3124
+ else
3125
+ agent.steer(notice);
3126
+ });
2764
3127
  }
2765
3128
  for (const memberId of ledger.affectedMembersOf(operation)) {
2766
3129
  const handle = this.handles.get(memberId);
2767
3130
  if (handle !== undefined)
2768
- this.notifyMember(handle.agent);
3131
+ this.afterCommit(`notification for '${this.memberLabel(memberId)}'`, () => this.notifyMember(handle.agent));
2769
3132
  }
2770
3133
  // Task acceptance no longer schedules standalone auto compaction: it is
2771
3134
  // a semantic checkpoint/context cue (delivered as ordinary Team
2772
3135
  // notification), and the Team pressure policy owns compaction entry.
2773
3136
  }
3137
+ /**
3138
+ * Run one effect that follows the ledger append. The operation is durable by
3139
+ * the time this runs, so a delivery or listener failure degrades to a log
3140
+ * line: the Remote answer stays the committed result instead of a false
3141
+ * rollback, and an Inbox wake that failed clears its own signature, so the
3142
+ * next commit touching that Member rederives the same durable facts and
3143
+ * retries without appending a second operation.
3144
+ *
3145
+ * An invariant divergence is the deliberate exception — the invariant
3146
+ * companion re-raises it on the caller's frame precisely to stay loud, and
3147
+ * swallowing it would silence a failed integrity check.
3148
+ */
3149
+ afterCommit(label, effect) {
3150
+ try {
3151
+ effect();
3152
+ }
3153
+ catch (error) {
3154
+ if (error instanceof AgentTeamInvariantError)
3155
+ throw error;
3156
+ this.ctx.logger.warn(`agent-team: post-commit ${label} failed: ${error instanceof Error ? error.message : String(error)}`);
3157
+ }
3158
+ }
2774
3159
  /** Model-visible active-Claim labels for the pressure notice. */
2775
3160
  activeClaimLabels(memberId) {
2776
3161
  const ledger = this.ledger;