switchroom 0.14.91 โ 0.14.93
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/agent-scheduler/index.js +1030 -56
- package/dist/auth-broker/index.js +50 -3
- package/dist/cli/notion-write-pretool.mjs +50 -3
- package/dist/cli/switchroom.js +1239 -906
- package/dist/host-control/main.js +50 -3
- package/dist/vault/approvals/kernel-server.js +51 -4
- package/dist/vault/broker/server.js +51 -4
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +77 -0
- package/profiles/_base/start.sh.hbs +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +139 -19
- package/telegram-plugin/gateway/cron-session.ts +45 -0
- package/telegram-plugin/gateway/gateway.ts +133 -10
- package/telegram-plugin/gateway/obligation-ledger.ts +47 -8
- package/telegram-plugin/gateway/turn-active-marker.ts +22 -0
- package/telegram-plugin/tests/cron-session.test.ts +32 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +63 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +85 -0
- package/telegram-plugin/tests/turn-active-marker.test.ts +28 -0
|
@@ -23781,7 +23781,7 @@ var init_dist = __esm(() => {
|
|
|
23781
23781
|
});
|
|
23782
23782
|
|
|
23783
23783
|
// ../src/config/schema.ts
|
|
23784
|
-
var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
|
|
23784
|
+
var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
|
|
23785
23785
|
var init_schema = __esm(() => {
|
|
23786
23786
|
init_zod();
|
|
23787
23787
|
CodeRepoEntrySchema = exports_external.object({
|
|
@@ -23794,15 +23794,54 @@ var init_schema = __esm(() => {
|
|
|
23794
23794
|
target: exports_external.string().optional().describe("Container path the source mounts to. Must be absolute. Defaults to " + "the same path as `source` (matches switchroom's existing dual-mount " + "convention so absolute paths in scaffolded scripts Just Work)."),
|
|
23795
23795
|
mode: exports_external.enum(["ro", "rw"]).optional().describe("Read-only (default) or read-write. Use `rw` only when the agent " + "must mutate the host path (e.g. editing switchroom source). " + "Default: 'ro'.")
|
|
23796
23796
|
});
|
|
23797
|
+
HttpDiffPollSchema = exports_external.object({
|
|
23798
|
+
type: exports_external.literal("http-diff"),
|
|
23799
|
+
url: exports_external.string().url().describe("Poll target. Host MUST match the operator egress allowlist (\u00a76.1) \u2014 " + "loopback/private/link-local/non-https are rejected; the IP is " + "resolve-then-pinned against DNS-rebind. Not agent-writable without " + "operator commit."),
|
|
23800
|
+
method: exports_external.enum(["GET", "POST"]).default("GET"),
|
|
23801
|
+
headers: exports_external.record(exports_external.string()).optional(),
|
|
23802
|
+
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault keys this poll may inject into request headers. Each is " + "HOST-PINNED (\u00a76.1): a secret may only be sent to the host it is bound " + "to in operator config, so an approved poll cannot exfil it elsewhere."),
|
|
23803
|
+
diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
|
|
23804
|
+
state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
|
|
23805
|
+
});
|
|
23806
|
+
TelegramReactionsPollSchema = exports_external.object({
|
|
23807
|
+
type: exports_external.literal("telegram-reactions"),
|
|
23808
|
+
chat_id: exports_external.union([exports_external.string().min(1), exports_external.number().int()]).describe("Chat to scan for reactions (model-free internal gateway query)."),
|
|
23809
|
+
emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68\u200d\uD83D\uDCBB)."),
|
|
23810
|
+
lookback: exports_external.number().int().positive().max(200).default(40),
|
|
23811
|
+
state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
|
|
23812
|
+
});
|
|
23813
|
+
PollSpecSchema = exports_external.discriminatedUnion("type", [
|
|
23814
|
+
HttpDiffPollSchema,
|
|
23815
|
+
TelegramReactionsPollSchema
|
|
23816
|
+
]);
|
|
23797
23817
|
ScheduleEntrySchema = exports_external.object({
|
|
23798
23818
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
23799
|
-
prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
|
|
23800
|
-
|
|
23819
|
+
prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
|
|
23820
|
+
kind: exports_external.enum(["poll", "prompt"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. Honoured only when SWITCHROOM_CHEAP_CRON is on."),
|
|
23821
|
+
poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
|
|
23822
|
+
model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) \u2014 the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
|
|
23823
|
+
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
|
|
23801
23824
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default \u2014 broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary \u2014 " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
|
|
23802
23825
|
topic: exports_external.union([
|
|
23803
23826
|
exports_external.string().min(1, "topic alias must be non-empty"),
|
|
23804
23827
|
exports_external.number().int().positive("topic ID must be a positive integer")
|
|
23805
23828
|
]).optional().describe("Forum topic this cron fires into when the owning agent is in " + "supergroup-owned mode (channels.telegram.chat_id set). Either a " + 'string alias resolved against `topic_aliases` (e.g. "planning") ' + "or a numeric topic ID. Falls back to the agent's `default_topic_id` " + "when unset. Ignored for agents in fleet-shared or dm_only mode. " + "Alias-resolution happens at config-load \u2014 typos surface immediately. " + "See docs/rfcs/supergroup-mode.md.")
|
|
23829
|
+
}).superRefine((entry, ctx) => {
|
|
23830
|
+
const kind = entry.kind ?? "prompt";
|
|
23831
|
+
if (kind === "poll" && !entry.poll) {
|
|
23832
|
+
ctx.addIssue({
|
|
23833
|
+
code: exports_external.ZodIssueCode.custom,
|
|
23834
|
+
path: ["poll"],
|
|
23835
|
+
message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
|
|
23836
|
+
});
|
|
23837
|
+
}
|
|
23838
|
+
if (kind === "prompt" && entry.poll) {
|
|
23839
|
+
ctx.addIssue({
|
|
23840
|
+
code: exports_external.ZodIssueCode.custom,
|
|
23841
|
+
path: ["poll"],
|
|
23842
|
+
message: "`poll` is only valid when kind: poll."
|
|
23843
|
+
});
|
|
23844
|
+
}
|
|
23806
23845
|
});
|
|
23807
23846
|
AgentSoulSchema = exports_external.object({
|
|
23808
23847
|
name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
|
|
@@ -24249,6 +24288,13 @@ var init_schema = __esm(() => {
|
|
|
24249
24288
|
config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
|
|
24250
24289
|
config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit \u00a75). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
|
|
24251
24290
|
});
|
|
24291
|
+
CronEgressSchema = exports_external.object({
|
|
24292
|
+
allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
|
|
24293
|
+
secret_bindings: exports_external.record(exports_external.string(), exports_external.string().min(1)).default({}).describe("secretName \u2192 the single host it may be sent to. A poll carrying a secret to any other host is rejected.")
|
|
24294
|
+
});
|
|
24295
|
+
CronConfigSchema = exports_external.object({
|
|
24296
|
+
egress: CronEgressSchema.optional().describe("SSRF/exfil fence for http-diff polls.")
|
|
24297
|
+
});
|
|
24252
24298
|
SwitchroomConfigSchema = exports_external.object({
|
|
24253
24299
|
switchroom: exports_external.object({
|
|
24254
24300
|
version: exports_external.literal(1).describe("Config schema version"),
|
|
@@ -24297,7 +24343,8 @@ var init_schema = __esm(() => {
|
|
|
24297
24343
|
profiles: exports_external.record(exports_external.string(), ProfileSchema).optional().describe("Named profile definitions. Agents reference via `extends: <name>`. " + "Inline profiles declared here take priority over filesystem " + "profiles/<name>/ directories when both exist."),
|
|
24298
24344
|
agents: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
|
|
24299
24345
|
message: "Agent name must start with a letter/digit, contain only lowercase letters/digits/hyphens/underscores, and be at most 51 characters (Telegram callback_data byte limit)"
|
|
24300
|
-
}), AgentSchema).describe("Map of agent name to agent configuration")
|
|
24346
|
+
}), AgentSchema).describe("Map of agent name to agent configuration"),
|
|
24347
|
+
cron: CronConfigSchema.optional().describe("Cheap-cron settings (docs/rfcs/cheap-cron-sessions.md). Operator-owned " + "egress allowlist + host-pinned secret bindings for Tier-0 http-diff " + "polls (\u00a76.1). Required to enable any http-diff poll; not agent-writable.")
|
|
24301
24348
|
});
|
|
24302
24349
|
});
|
|
24303
24350
|
|
|
@@ -47522,6 +47569,18 @@ function createPendingInboundBuffer(opts = {}) {
|
|
|
47522
47569
|
};
|
|
47523
47570
|
}
|
|
47524
47571
|
|
|
47572
|
+
// gateway/cron-session.ts
|
|
47573
|
+
var CRON_IDENTITY_SUFFIX = "-cron";
|
|
47574
|
+
function cronIdentity(agent) {
|
|
47575
|
+
return `${agent}${CRON_IDENTITY_SUFFIX}`;
|
|
47576
|
+
}
|
|
47577
|
+
function isCronIdentity(name) {
|
|
47578
|
+
return typeof name === "string" && name.endsWith(CRON_IDENTITY_SUFFIX);
|
|
47579
|
+
}
|
|
47580
|
+
function resolveInjectTarget(agentName3, meta) {
|
|
47581
|
+
return meta?.session === "cron" ? cronIdentity(agentName3) : agentName3;
|
|
47582
|
+
}
|
|
47583
|
+
|
|
47525
47584
|
// gateway/obligation-ledger.ts
|
|
47526
47585
|
class ObligationLedger {
|
|
47527
47586
|
maxRepresents;
|
|
@@ -47578,18 +47637,21 @@ class ObligationLedger {
|
|
|
47578
47637
|
return best;
|
|
47579
47638
|
}
|
|
47580
47639
|
decideAtIdle(opts) {
|
|
47581
|
-
const
|
|
47640
|
+
const useEligible = opts != null && (opts.graceMs > 0 || opts.backgroundWorkActive === true);
|
|
47641
|
+
const o = useEligible ? this.oldestEligible(opts.now, opts.graceMs, opts.backgroundWorkActive === true, opts.backgroundGraceMs ?? 0) : this.oldest();
|
|
47582
47642
|
if (o === undefined)
|
|
47583
47643
|
return { action: "none" };
|
|
47584
47644
|
if (o.representCount >= this.maxRepresents)
|
|
47585
47645
|
return { action: "escalate", obligation: o };
|
|
47586
47646
|
return { action: "represent", obligation: o };
|
|
47587
47647
|
}
|
|
47588
|
-
oldestEligible(now, graceMs) {
|
|
47648
|
+
oldestEligible(now, graceMs, backgroundWorkActive, backgroundGraceMs) {
|
|
47589
47649
|
let best;
|
|
47590
47650
|
for (const o of this.open.values()) {
|
|
47591
47651
|
if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs)
|
|
47592
47652
|
continue;
|
|
47653
|
+
if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
|
|
47654
|
+
continue;
|
|
47593
47655
|
if (best === undefined || o.openedAt < best.openedAt)
|
|
47594
47656
|
best = o;
|
|
47595
47657
|
}
|
|
@@ -52898,13 +52960,22 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52898
52960
|
return false;
|
|
52899
52961
|
}
|
|
52900
52962
|
}
|
|
52963
|
+
function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
52964
|
+
const path = join32(stateDir, TURN_ACTIVE_MARKER_FILE2);
|
|
52965
|
+
try {
|
|
52966
|
+
const st = statSync10(path);
|
|
52967
|
+
return (now ?? Date.now()) - st.mtimeMs;
|
|
52968
|
+
} catch {
|
|
52969
|
+
return null;
|
|
52970
|
+
}
|
|
52971
|
+
}
|
|
52901
52972
|
|
|
52902
52973
|
// ../src/build-info.ts
|
|
52903
|
-
var VERSION = "0.14.
|
|
52904
|
-
var COMMIT_SHA = "
|
|
52905
|
-
var COMMIT_DATE = "2026-06-
|
|
52906
|
-
var LATEST_PR =
|
|
52907
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
52974
|
+
var VERSION = "0.14.93";
|
|
52975
|
+
var COMMIT_SHA = "87b62902";
|
|
52976
|
+
var COMMIT_DATE = "2026-06-10T08:22:44+10:00";
|
|
52977
|
+
var LATEST_PR = null;
|
|
52978
|
+
var COMMITS_AHEAD_OF_TAG = 3;
|
|
52908
52979
|
|
|
52909
52980
|
// gateway/boot-version.ts
|
|
52910
52981
|
function formatRelativeAgo(iso) {
|
|
@@ -54129,6 +54200,14 @@ var OBLIGATION_ESCALATE_GRACE_MS = (() => {
|
|
|
54129
54200
|
const n = Number(raw);
|
|
54130
54201
|
return Number.isFinite(n) && n >= 0 ? n : 45000;
|
|
54131
54202
|
})();
|
|
54203
|
+
var OBLIGATION_BACKGROUND_WORK_GRACE_MS = (() => {
|
|
54204
|
+
const raw = process.env.SWITCHROOM_OBLIGATION_BACKGROUND_WORK_GRACE_MS;
|
|
54205
|
+
if (raw == null || raw === "")
|
|
54206
|
+
return 1200000;
|
|
54207
|
+
const n = Number(raw);
|
|
54208
|
+
return Number.isFinite(n) && n >= 0 ? n : 1200000;
|
|
54209
|
+
})();
|
|
54210
|
+
var TURN_ACTIVE_MARKER_FRESH_MS = 90000;
|
|
54132
54211
|
var AUTOCLASSIFY_MIDTURN_SHADOW = process.env.SWITCHROOM_AUTOCLASSIFY_MIDTURN_SHADOW !== "0";
|
|
54133
54212
|
var lastAgentOutputAt = new Map;
|
|
54134
54213
|
var LAST_OUTPUT_MAX_KEYS = 512;
|
|
@@ -55646,6 +55725,13 @@ var inboundSpool = STATIC ? undefined : createInboundSpool({
|
|
|
55646
55725
|
}
|
|
55647
55726
|
});
|
|
55648
55727
|
var pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool });
|
|
55728
|
+
function agentHasInFlightBackgroundWork(now) {
|
|
55729
|
+
if (countRunningWorkers() > 0)
|
|
55730
|
+
return true;
|
|
55731
|
+
const ageMs = readTurnActiveMarkerAgeMs(STATE_DIR, now);
|
|
55732
|
+
return ageMs != null && ageMs < TURN_ACTIVE_MARKER_FRESH_MS;
|
|
55733
|
+
}
|
|
55734
|
+
var lastBgWorkDeferLogMs = 0;
|
|
55649
55735
|
function obligationSweep() {
|
|
55650
55736
|
if (!OBLIGATION_LEDGER_ENABLED)
|
|
55651
55737
|
return;
|
|
@@ -55654,10 +55740,23 @@ function obligationSweep() {
|
|
|
55654
55740
|
if (turnInFlightForGate())
|
|
55655
55741
|
return;
|
|
55656
55742
|
const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
55657
|
-
const
|
|
55743
|
+
const now = Date.now();
|
|
55744
|
+
const backgroundWorkActive = OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now);
|
|
55745
|
+
const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive ? {
|
|
55746
|
+
now,
|
|
55747
|
+
graceMs: OBLIGATION_ESCALATE_GRACE_MS,
|
|
55748
|
+
backgroundWorkActive,
|
|
55749
|
+
backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS
|
|
55750
|
+
} : undefined);
|
|
55658
55751
|
const o = decision.obligation;
|
|
55659
|
-
if (decision.action === "none" || o == null)
|
|
55752
|
+
if (decision.action === "none" || o == null) {
|
|
55753
|
+
if (backgroundWorkActive && obligationLedger.hasOpen() && now - lastBgWorkDeferLogMs > 60000) {
|
|
55754
|
+
lastBgWorkDeferLogMs = now;
|
|
55755
|
+
process.stderr.write(`telegram gateway: obligation sweep deferred \u2014 in-flight autonomous sub-agent work ` + `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60000)}m from receipt)
|
|
55756
|
+
`);
|
|
55757
|
+
}
|
|
55660
55758
|
return;
|
|
55759
|
+
}
|
|
55661
55760
|
if (decision.action === "represent") {
|
|
55662
55761
|
if (pendingInboundBuffer.depth(agent) > 0)
|
|
55663
55762
|
return;
|
|
@@ -55718,6 +55817,16 @@ var ipcServer = createIpcServer({
|
|
|
55718
55817
|
onClientRegistered(client3) {
|
|
55719
55818
|
process.stderr.write(`telegram gateway: bridge registered \u2014 agent=${client3.agentName}
|
|
55720
55819
|
`);
|
|
55820
|
+
if (isCronIdentity(client3.agentName)) {
|
|
55821
|
+
client3.send({ type: "status", status: "agent_connected" });
|
|
55822
|
+
const pending2 = pendingInboundBuffer.drain(client3.agentName ?? "");
|
|
55823
|
+
for (const m of pending2) {
|
|
55824
|
+
try {
|
|
55825
|
+
client3.send(m);
|
|
55826
|
+
} catch {}
|
|
55827
|
+
}
|
|
55828
|
+
return;
|
|
55829
|
+
}
|
|
55721
55830
|
const bridgeUpEffects = client3.agentName != null ? shadowEmit({ kind: "bridgeUp", at: Date.now() }) : [];
|
|
55722
55831
|
client3.send({ type: "status", status: "agent_connected" });
|
|
55723
55832
|
if (client3.agentName != null) {
|
|
@@ -55840,6 +55949,11 @@ var ipcServer = createIpcServer({
|
|
|
55840
55949
|
}
|
|
55841
55950
|
},
|
|
55842
55951
|
onClientDisconnected(client3) {
|
|
55952
|
+
if (isCronIdentity(client3.agentName)) {
|
|
55953
|
+
process.stderr.write(`telegram gateway: cron-session bridge disconnected \u2014 agent=${client3.agentName}
|
|
55954
|
+
`);
|
|
55955
|
+
return;
|
|
55956
|
+
}
|
|
55843
55957
|
if (client3.agentName != null) {
|
|
55844
55958
|
process.stderr.write(`telegram gateway: bridge disconnected \u2014 agent=${client3.agentName}
|
|
55845
55959
|
`);
|
|
@@ -55887,7 +56001,9 @@ var ipcServer = createIpcServer({
|
|
|
55887
56001
|
};
|
|
55888
56002
|
}
|
|
55889
56003
|
},
|
|
55890
|
-
onSessionEvent(
|
|
56004
|
+
onSessionEvent(client3, msg) {
|
|
56005
|
+
if (isCronIdentity(client3.agentName))
|
|
56006
|
+
return;
|
|
55891
56007
|
if (msg.activeFile)
|
|
55892
56008
|
lastSessionActiveFile = msg.activeFile;
|
|
55893
56009
|
const ev = msg.event;
|
|
@@ -55987,7 +56103,9 @@ var ipcServer = createIpcServer({
|
|
|
55987
56103
|
firstSeenAt: new Date
|
|
55988
56104
|
});
|
|
55989
56105
|
},
|
|
55990
|
-
onPtyPartial(
|
|
56106
|
+
onPtyPartial(client3, msg) {
|
|
56107
|
+
if (isCronIdentity(client3.agentName))
|
|
56108
|
+
return;
|
|
55991
56109
|
handlePtyPartial(msg.text);
|
|
55992
56110
|
},
|
|
55993
56111
|
async onRequestDriveApproval(client3, msg) {
|
|
@@ -56212,13 +56330,15 @@ var ipcServer = createIpcServer({
|
|
|
56212
56330
|
onInjectInbound(_client, msg) {
|
|
56213
56331
|
const promptKey = typeof msg.inbound.meta?.prompt_key === "string" ? msg.inbound.meta.prompt_key : "unknown";
|
|
56214
56332
|
const source = typeof msg.inbound.meta?.source === "string" ? msg.inbound.meta.source : "unknown";
|
|
56215
|
-
const
|
|
56216
|
-
|
|
56333
|
+
const target = resolveInjectTarget(msg.agentName, msg.inbound.meta);
|
|
56334
|
+
const toCron = target !== msg.agentName;
|
|
56335
|
+
const delivered = ipcServer.sendToAgent(target, msg.inbound);
|
|
56336
|
+
if (delivered && !toCron)
|
|
56217
56337
|
markClaudeBusyForInbound(msg.inbound);
|
|
56218
|
-
process.stderr.write(`telegram gateway: inject_inbound agent=${msg.agentName} source=${source} prompt_key=${promptKey} delivered=${delivered}
|
|
56338
|
+
process.stderr.write(`telegram gateway: inject_inbound agent=${msg.agentName} target=${target} source=${source} prompt_key=${promptKey} delivered=${delivered}
|
|
56219
56339
|
`);
|
|
56220
56340
|
if (!delivered) {
|
|
56221
|
-
pendingInboundBuffer.push(
|
|
56341
|
+
pendingInboundBuffer.push(target, msg.inbound);
|
|
56222
56342
|
}
|
|
56223
56343
|
},
|
|
56224
56344
|
onQuotaWallDetected(_client, msg) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheap-cron session identity โ docs/rfcs/cheap-cron-sessions.md ยง3.3.
|
|
3
|
+
*
|
|
4
|
+
* Rather than rekey the gateway's hardened single-bridge machinery
|
|
5
|
+
* (agentIndex / pendingInboundBuffer / handleRegister, each carrying
|
|
6
|
+
* subtle race fixes), a Tier-1 cron fire is routed to a SECOND bridge
|
|
7
|
+
* that registers under a DERIVED identity `<agent>-cron`. To the IPC
|
|
8
|
+
* layer it is "just another agent", so routing, buffering, disconnect,
|
|
9
|
+
* and heartbeat all work unchanged. The gateway gates its SINGLETON
|
|
10
|
+
* status machinery (shadow bridge-state, boot card, currentTurn /
|
|
11
|
+
* progress card / silence-poke) off the cron identity โ which IS the
|
|
12
|
+
* ยง2.4 "cron session is status-silent" requirement, so it is one change,
|
|
13
|
+
* not two.
|
|
14
|
+
*
|
|
15
|
+
* The cron session's bridge sets SWITCHROOM_AGENT_NAME=`<agent>-cron`;
|
|
16
|
+
* the scheduler emits `meta.session='cron'` and the gateway derives the
|
|
17
|
+
* target via `cronIdentity()`. Pure string fns โ pinned in cron-session.test.ts.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Suffix that distinguishes a cron-session bridge from the main agent bridge. */
|
|
21
|
+
export const CRON_IDENTITY_SUFFIX = "-cron";
|
|
22
|
+
|
|
23
|
+
/** Derive the cron-session bridge identity for an agent. */
|
|
24
|
+
export function cronIdentity(agent: string): string {
|
|
25
|
+
return `${agent}${CRON_IDENTITY_SUFFIX}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True iff `name` is a cron-session bridge identity (not a main agent bridge). */
|
|
29
|
+
export function isCronIdentity(name: string | null | undefined): boolean {
|
|
30
|
+
return typeof name === "string" && name.endsWith(CRON_IDENTITY_SUFFIX);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The real agent name behind a (possibly cron) identity. */
|
|
34
|
+
export function baseAgent(name: string): string {
|
|
35
|
+
return isCronIdentity(name) ? name.slice(0, -CRON_IDENTITY_SUFFIX.length) : name;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the IPC routing target for an inject_inbound. When the fire
|
|
40
|
+
* carries `meta.session='cron'` it goes to the derived cron bridge; every
|
|
41
|
+
* other fire (and all of today's callers) goes to the agent unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveInjectTarget(agentName: string, meta: Record<string, string> | undefined): string {
|
|
44
|
+
return meta?.session === "cron" ? cronIdentity(agentName) : agentName;
|
|
45
|
+
}
|
|
@@ -287,6 +287,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
|
287
287
|
import { handleRequestMs365Approval } from './ms365-write-approval.js'
|
|
288
288
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
289
289
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
290
|
+
import { isCronIdentity, resolveInjectTarget } from './cron-session.js'
|
|
290
291
|
import {
|
|
291
292
|
ObligationLedger,
|
|
292
293
|
buildObligationRepresentInbound,
|
|
@@ -428,6 +429,7 @@ import {
|
|
|
428
429
|
touchTurnActiveMarker,
|
|
429
430
|
removeTurnActiveMarker,
|
|
430
431
|
sweepStaleTurnActiveMarker,
|
|
432
|
+
readTurnActiveMarkerAgeMs,
|
|
431
433
|
TURN_ACTIVE_MARKER_FILE,
|
|
432
434
|
} from './turn-active-marker.js'
|
|
433
435
|
import {
|
|
@@ -1467,6 +1469,34 @@ const OBLIGATION_ESCALATE_GRACE_MS = (() => {
|
|
|
1467
1469
|
return Number.isFinite(n) && n >= 0 ? n : 45_000
|
|
1468
1470
|
})()
|
|
1469
1471
|
|
|
1472
|
+
// Background-work escalate-grace ceiling. The 45s grace above is far too short
|
|
1473
|
+
// for extended-autonomous sub-agent work: an agent that ack-firsts ("on it")
|
|
1474
|
+
// then delegates to a background worker OR an orphaned foreground sub-agent ends
|
|
1475
|
+
// its FOREGROUND turn in seconds, but the real answer lands minutes later. The
|
|
1476
|
+
// turn-in-flight machine (turn already ended) doesn't see that work, so the
|
|
1477
|
+
// sweep would re-present/escalate a false "โ ๏ธ I may have missed this โ re-send"
|
|
1478
|
+
// while the agent is genuinely researching (the gymbro liven-research case,
|
|
1479
|
+
// 2026-06-10). While `agentHasInFlightBackgroundWork()` holds, an open
|
|
1480
|
+
// obligation younger than THIS ceiling (from openedAt) is skipped. Bounded BY
|
|
1481
|
+
// CONSTRUCTION โ a hard wall-clock ceiling, so even a stuck/leaked worker can't
|
|
1482
|
+
// suppress escalation forever; the obligation FSM still terminates. This also
|
|
1483
|
+
// preserves the represent budget across a restart that kills the work: with the
|
|
1484
|
+
// false represents suppressed, the hydrated obligation re-presents (resumes the
|
|
1485
|
+
// research) instead of prematurely escalating. Kill switch: =0 โ pre-fix
|
|
1486
|
+
// behaviour (no background-work grace).
|
|
1487
|
+
const OBLIGATION_BACKGROUND_WORK_GRACE_MS = (() => {
|
|
1488
|
+
const raw = process.env.SWITCHROOM_OBLIGATION_BACKGROUND_WORK_GRACE_MS
|
|
1489
|
+
if (raw == null || raw === '') return 20 * 60_000 // 20 min โ generous for real research, still bounded
|
|
1490
|
+
const n = Number(raw)
|
|
1491
|
+
return Number.isFinite(n) && n >= 0 ? n : 20 * 60_000
|
|
1492
|
+
})()
|
|
1493
|
+
// Marker-freshness window for the orphaned-foreground signal. The turn-active
|
|
1494
|
+
// marker is touched on every foreground tool_use and on foreground sub-agent
|
|
1495
|
+
// JSONL growth, so an mtime younger than this means a sub-agent is touching it
|
|
1496
|
+
// RIGHT NOW; older โ the work stopped (or the marker leaked) โ not active.
|
|
1497
|
+
// Comfortably exceeds the sub-agent poll cadence so it doesn't flap.
|
|
1498
|
+
const TURN_ACTIVE_MARKER_FRESH_MS = 90_000
|
|
1499
|
+
|
|
1470
1500
|
// โโโ Mid-turn auto-classify (steer-vs-queue), SHADOW mode โโโโโโโโโโโโโโโโโโโโโ
|
|
1471
1501
|
// Today a no-prefix mid-turn message always QUEUES. autoClassifyMidTurnInbound
|
|
1472
1502
|
// (auto-classify-mid-turn.ts) is the basis for a smarter default using
|
|
@@ -5323,24 +5353,72 @@ const pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool })
|
|
|
5323
5353
|
// disconnect (disconnect-flush.ts), and by the 300s silence-poke watchdog;
|
|
5324
5354
|
// (3) the escalation send settles โ bounded BY CONSTRUCTION via withDeadline
|
|
5325
5355
|
// below (grammy has no request timeout, so an unbounded send was the one
|
|
5326
|
-
// way an obligation could get stuck OPEN forever โ now closed)
|
|
5356
|
+
// way an obligation could get stuck OPEN forever โ now closed);
|
|
5357
|
+
// (4) the background-work grace releases โ an obligation skipped because
|
|
5358
|
+
// agentHasInFlightBackgroundWork() is true is bounded by
|
|
5359
|
+
// OBLIGATION_BACKGROUND_WORK_GRACE_MS (a hard wall-clock ceiling from
|
|
5360
|
+
// openedAt). Even a permanently-stuck/leaked worker signal cannot suppress
|
|
5361
|
+
// the act past the ceiling, so this adds NO unbounded liveness dependency:
|
|
5362
|
+
// decideAtIdle ignores the work signal once now โฅ openedAt + ceiling, ฮผ
|
|
5363
|
+
// resumes decreasing, and termination still holds.
|
|
5327
5364
|
// The only residual liveness assumption is the bridge eventually reconnecting /
|
|
5328
5365
|
// the process restarting, which the entire gateway's inbound delivery already
|
|
5329
5366
|
// depends on and which durable spool + boot-replay make self-healing.
|
|
5367
|
+
// True when the agent has in-flight autonomous sub-agent work the turn-in-flight
|
|
5368
|
+
// gate does NOT see: a running background worker (countRunningWorkers โ its row
|
|
5369
|
+
// is INSERTed status='running' at dispatch, before the parent turn ends), OR an
|
|
5370
|
+
// orphaned/extended-autonomous FOREGROUND sub-agent that outlived its turn and is
|
|
5371
|
+
// still touching the turn-active marker (#2240; background activity deliberately
|
|
5372
|
+
// does NOT touch the parent marker, so the two signals are complementary).
|
|
5373
|
+
// Used ONLY by the obligation sweep to bound a false escalation during genuine
|
|
5374
|
+
// post-turn work. The caller already established the turn machine is idle (the
|
|
5375
|
+
// `turnInFlightForGate()` early-return), so a fresh marker here means orphaned
|
|
5376
|
+
// sub-agent activity (or a just-ended turn within the freshness window โ a
|
|
5377
|
+
// harmless small extra grace, bounded by the ceiling either way).
|
|
5378
|
+
function agentHasInFlightBackgroundWork(now: number): boolean {
|
|
5379
|
+
if (countRunningWorkers() > 0) return true
|
|
5380
|
+
const ageMs = readTurnActiveMarkerAgeMs(STATE_DIR, now)
|
|
5381
|
+
return ageMs != null && ageMs < TURN_ACTIVE_MARKER_FRESH_MS
|
|
5382
|
+
}
|
|
5383
|
+
// Throttle for the background-work defer diagnostic (the 5s sweep would otherwise
|
|
5384
|
+
// log every tick across a multi-minute research window).
|
|
5385
|
+
let lastBgWorkDeferLogMs = 0
|
|
5330
5386
|
function obligationSweep(): void {
|
|
5331
5387
|
if (!OBLIGATION_LEDGER_ENABLED) return
|
|
5332
5388
|
if (!obligationLedger.hasOpen()) return
|
|
5333
5389
|
if (turnInFlightForGate()) return // a turn is running โ let it finish/answer
|
|
5334
5390
|
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
5391
|
+
const now = Date.now()
|
|
5392
|
+
// Background-work grace: while genuine autonomous sub-agent work is in flight
|
|
5393
|
+
// (a running worker, or an orphaned foreground sub-agent โ neither visible to
|
|
5394
|
+
// the turn machine), an obligation younger than the ceiling is NOT re-presented
|
|
5395
|
+
// /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS so escalation
|
|
5396
|
+
// always eventually fires. =0 disables it.
|
|
5397
|
+
const backgroundWorkActive =
|
|
5398
|
+
OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now)
|
|
5335
5399
|
// Grace window: skip an obligation whose handling turn ended < grace ago โ its
|
|
5336
5400
|
// trailing slow/worker answer may still be landing (over-escalation fix).
|
|
5337
5401
|
const decision = obligationLedger.decideAtIdle(
|
|
5338
|
-
OBLIGATION_ESCALATE_GRACE_MS > 0
|
|
5339
|
-
? {
|
|
5402
|
+
OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive
|
|
5403
|
+
? {
|
|
5404
|
+
now,
|
|
5405
|
+
graceMs: OBLIGATION_ESCALATE_GRACE_MS,
|
|
5406
|
+
backgroundWorkActive,
|
|
5407
|
+
backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS,
|
|
5408
|
+
}
|
|
5340
5409
|
: undefined,
|
|
5341
5410
|
)
|
|
5342
5411
|
const o = decision.obligation
|
|
5343
|
-
if (decision.action === 'none' || o == null)
|
|
5412
|
+
if (decision.action === 'none' || o == null) {
|
|
5413
|
+
if (backgroundWorkActive && obligationLedger.hasOpen() && now - lastBgWorkDeferLogMs > 60_000) {
|
|
5414
|
+
lastBgWorkDeferLogMs = now
|
|
5415
|
+
process.stderr.write(
|
|
5416
|
+
`telegram gateway: obligation sweep deferred โ in-flight autonomous sub-agent work ` +
|
|
5417
|
+
`(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60_000)}m from receipt)\n`,
|
|
5418
|
+
)
|
|
5419
|
+
}
|
|
5420
|
+
return
|
|
5421
|
+
}
|
|
5344
5422
|
if (decision.action === 'represent') {
|
|
5345
5423
|
// Re-present goes through the bridge โ buffer. Only the represent path is
|
|
5346
5424
|
// gated on an empty buffer (let the existing drain run first, avoid
|
|
@@ -5474,6 +5552,19 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5474
5552
|
|
|
5475
5553
|
onClientRegistered(client: IpcClient) {
|
|
5476
5554
|
process.stderr.write(`telegram gateway: bridge registered โ agent=${client.agentName}\n`)
|
|
5555
|
+
// Cheap-cron (ยง2.4/ยง3.3): a `<agent>-cron` bridge is the Tier-1 cheap
|
|
5556
|
+
// session. It is STATUS-SILENT โ it must NOT drive the gateway's
|
|
5557
|
+
// singleton machinery (shadow bridge-state, warmup, boot card, which all
|
|
5558
|
+
// track the MAIN agent's liveness). Drain any buffered cron fire to it
|
|
5559
|
+
// (so a fire that triggered a lazy spawn lands), then return early.
|
|
5560
|
+
if (isCronIdentity(client.agentName)) {
|
|
5561
|
+
client.send({ type: 'status', status: 'agent_connected' })
|
|
5562
|
+
const pending = pendingInboundBuffer.drain(client.agentName ?? '')
|
|
5563
|
+
for (const m of pending) {
|
|
5564
|
+
try { client.send(m) } catch { /* cron fire drop โ best-effort, like today's cron */ }
|
|
5565
|
+
}
|
|
5566
|
+
return
|
|
5567
|
+
}
|
|
5477
5568
|
// Phase 2b shadow: ONLY emit bridgeUp for the REAL bridge sidecar
|
|
5478
5569
|
// (with an agent name). Anonymous IPC clients (recall.py, mcp
|
|
5479
5570
|
// handshakes, etc.) connect briefly without a name and would
|
|
@@ -5652,6 +5743,17 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5652
5743
|
},
|
|
5653
5744
|
|
|
5654
5745
|
onClientDisconnected(client: IpcClient) {
|
|
5746
|
+
// Cheap-cron ยง2.4: a `<agent>-cron` bridge is the status-silent cron
|
|
5747
|
+
// session. Its disconnect (e.g. B3 lazy idle-teardown) must NOT touch
|
|
5748
|
+
// the MAIN agent's singleton state โ emitting bridgeDown (the shadow
|
|
5749
|
+
// state is unkeyed) or flushOnAgentDisconnect (flushes the main agent's
|
|
5750
|
+
// active reactions / disposes its progress driver mid-turn) would be the
|
|
5751
|
+
// "premature ๐" bug for the main session. Symmetric to the cron gate in
|
|
5752
|
+
// onClientRegistered / onSessionEvent / onPtyPartial.
|
|
5753
|
+
if (isCronIdentity(client.agentName)) {
|
|
5754
|
+
process.stderr.write(`telegram gateway: cron-session bridge disconnected โ agent=${client.agentName}\n`)
|
|
5755
|
+
return
|
|
5756
|
+
}
|
|
5655
5757
|
// ONLY log "bridge disconnected" + emit bridgeDown for the REAL
|
|
5656
5758
|
// bridge sidecar (matching the bridgeUp gate above). Anonymous IPC
|
|
5657
5759
|
// clients โ e.g. recall.py's one-shot legacy update_placeholder
|
|
@@ -5722,7 +5824,13 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5722
5824
|
}
|
|
5723
5825
|
},
|
|
5724
5826
|
|
|
5725
|
-
onSessionEvent(
|
|
5827
|
+
onSessionEvent(client: IpcClient, msg: SessionEventForward) {
|
|
5828
|
+
// Cheap-cron ยง2.4: the cron session is status-silent โ its session
|
|
5829
|
+
// events must not drive the main agent's progress card, transcript
|
|
5830
|
+
// tail, currentTurn, or silence-poke. Its reply still flows (onToolCall
|
|
5831
|
+
// is not gated). currentTurn was never set for a cron fire (onInjectInbound
|
|
5832
|
+
// skips markClaudeBusy), so the card machinery has nothing to attach to.
|
|
5833
|
+
if (isCronIdentity(client.agentName)) return
|
|
5726
5834
|
// Track the session-tail's attached file for the proactive-
|
|
5727
5835
|
// compaction occupancy read (see maybeProactiveCompact).
|
|
5728
5836
|
if (msg.activeFile) lastSessionActiveFile = msg.activeFile
|
|
@@ -5954,7 +6062,10 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
5954
6062
|
* `handlePtyPartial` does its own buffering for the
|
|
5955
6063
|
* partial-before-enqueue race.
|
|
5956
6064
|
*/
|
|
5957
|
-
onPtyPartial(
|
|
6065
|
+
onPtyPartial(client: IpcClient, msg: PtyPartialForward) {
|
|
6066
|
+
// Cheap-cron ยง2.4: cron session is status-silent โ no visible reply
|
|
6067
|
+
// stream from its PTY tail (it would edit a card the main session owns).
|
|
6068
|
+
if (isCronIdentity(client.agentName)) return
|
|
5958
6069
|
handlePtyPartial(msg.text)
|
|
5959
6070
|
},
|
|
5960
6071
|
|
|
@@ -6291,16 +6402,28 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
6291
6402
|
const source = typeof msg.inbound.meta?.source === 'string'
|
|
6292
6403
|
? msg.inbound.meta.source
|
|
6293
6404
|
: 'unknown'
|
|
6294
|
-
|
|
6295
|
-
|
|
6405
|
+
// Cheap-cron (docs/rfcs/cheap-cron-sessions.md ยง3.3): a Tier-1 fire
|
|
6406
|
+
// carries meta.session='cron' โ route to the derived `<agent>-cron`
|
|
6407
|
+
// bridge (a 2nd interactive Sonnet session in the same container).
|
|
6408
|
+
// Every other fire (and all of today's callers) routes to the agent
|
|
6409
|
+
// unchanged. Route+buffer share the same target so a fire that lands
|
|
6410
|
+
// mid cron-session-spawn buffers under the cron identity and drains to
|
|
6411
|
+
// it on register.
|
|
6412
|
+
const target = resolveInjectTarget(msg.agentName, msg.inbound.meta)
|
|
6413
|
+
const toCron = target !== msg.agentName
|
|
6414
|
+
const delivered = ipcServer.sendToAgent(target, msg.inbound)
|
|
6415
|
+
// Status-silent (ยง2.4): a cron fire must NOT set the MAIN agent's
|
|
6416
|
+
// currentTurn (progress card / silence-poke). The cron session is
|
|
6417
|
+
// fire-and-forget; its reply is its only Telegram surface.
|
|
6418
|
+
if (delivered && !toCron) markClaudeBusyForInbound(msg.inbound)
|
|
6296
6419
|
process.stderr.write(
|
|
6297
|
-
`telegram gateway: inject_inbound agent=${msg.agentName} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
|
|
6420
|
+
`telegram gateway: inject_inbound agent=${msg.agentName} target=${target} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
|
|
6298
6421
|
)
|
|
6299
6422
|
// #1150: same buffer-on-failure pattern as vault_grant_approved.
|
|
6300
6423
|
// Cron fires use this path too โ if a cron-driven wake-up lands
|
|
6301
6424
|
// mid bridge-reconnect, buffer it for the next register.
|
|
6302
6425
|
if (!delivered) {
|
|
6303
|
-
pendingInboundBuffer.push(
|
|
6426
|
+
pendingInboundBuffer.push(target, msg.inbound)
|
|
6304
6427
|
}
|
|
6305
6428
|
},
|
|
6306
6429
|
|
|
@@ -187,22 +187,61 @@ export class ObligationLedger {
|
|
|
187
187
|
* genuinely-stale one is still acted on while a freshly-ended one waits. Pure
|
|
188
188
|
* (clock injected via opts.now, mirroring the builder convention). With no opts
|
|
189
189
|
* (or graceMs<=0) this is the pre-grace behaviour exactly.
|
|
190
|
+
*
|
|
191
|
+
* BACKGROUND-WORK GRACE (opts.backgroundWorkActive): the 45s `graceMs` above is
|
|
192
|
+
* far too short for extended-autonomous sub-agent work โ an agent that
|
|
193
|
+
* ack-firsts ("on it") then delegates to a background worker or an orphaned
|
|
194
|
+
* foreground sub-agent ends its FOREGROUND turn in seconds, but the real answer
|
|
195
|
+
* lands minutes later. The in-flight machine (turn already ended) does not see
|
|
196
|
+
* that work, so the sweep would re-present/escalate a false "did I miss this?
|
|
197
|
+
* re-send" while the agent is genuinely researching (the gymbro liven case,
|
|
198
|
+
* 2026-06-10). When the gateway reports `backgroundWorkActive` (a running worker
|
|
199
|
+
* or a freshly-touched turn-active marker), an obligation younger than
|
|
200
|
+
* `backgroundGraceMs` (measured from openedAt) is SKIPPED. Bounded BY
|
|
201
|
+
* CONSTRUCTION: `backgroundGraceMs` is a hard wall-clock ceiling, so even a
|
|
202
|
+
* pathologically-stuck/leaked worker cannot suppress the escalation forever โ
|
|
203
|
+
* once openedAt+backgroundGraceMs passes, the obligation is acted on regardless
|
|
204
|
+
* of work state, and the FSM still terminates.
|
|
190
205
|
*/
|
|
191
|
-
decideAtIdle(opts?: {
|
|
192
|
-
|
|
193
|
-
|
|
206
|
+
decideAtIdle(opts?: {
|
|
207
|
+
now: number
|
|
208
|
+
graceMs: number
|
|
209
|
+
backgroundWorkActive?: boolean
|
|
210
|
+
backgroundGraceMs?: number
|
|
211
|
+
}): LedgerDecision {
|
|
212
|
+
const useEligible = opts != null && (opts.graceMs > 0 || opts.backgroundWorkActive === true)
|
|
213
|
+
const o = useEligible
|
|
214
|
+
? this.oldestEligible(
|
|
215
|
+
opts!.now,
|
|
216
|
+
opts!.graceMs,
|
|
217
|
+
opts!.backgroundWorkActive === true,
|
|
218
|
+
opts!.backgroundGraceMs ?? 0,
|
|
219
|
+
)
|
|
220
|
+
: this.oldest()
|
|
194
221
|
if (o === undefined) return { action: 'none' }
|
|
195
222
|
if (o.representCount >= this.maxRepresents) return { action: 'escalate', obligation: o }
|
|
196
223
|
return { action: 'represent', obligation: o }
|
|
197
224
|
}
|
|
198
225
|
|
|
199
|
-
/** The oldest open obligation
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
|
|
226
|
+
/** The oldest open obligation that is currently ELIGIBLE to act on โ i.e. NOT
|
|
227
|
+
* within either grace window:
|
|
228
|
+
* - trailing-answer grace: its handling turn ended < `graceMs` ago (a queued
|
|
229
|
+
* obligation with no lastTurnEndedAt can't have a trailing answer, so it is
|
|
230
|
+
* always eligible on this axis); AND
|
|
231
|
+
* - background-work grace: when `backgroundWorkActive`, it was opened <
|
|
232
|
+
* `backgroundGraceMs` ago (genuine in-flight autonomous work โ bounded by
|
|
233
|
+
* the ceiling so a stale/leaked worker can't suppress escalation forever). */
|
|
234
|
+
private oldestEligible(
|
|
235
|
+
now: number,
|
|
236
|
+
graceMs: number,
|
|
237
|
+
backgroundWorkActive: boolean,
|
|
238
|
+
backgroundGraceMs: number,
|
|
239
|
+
): Obligation | undefined {
|
|
203
240
|
let best: Obligation | undefined
|
|
204
241
|
for (const o of this.open.values()) {
|
|
205
|
-
if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs) continue //
|
|
242
|
+
if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs) continue // trailing-answer grace
|
|
243
|
+
if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
|
|
244
|
+
continue // in-flight autonomous work, bounded by the ceiling
|
|
206
245
|
if (best === undefined || o.openedAt < best.openedAt) best = o
|
|
207
246
|
}
|
|
208
247
|
return best
|