@bivy/bivy 0.5.1-staging.63 → 0.5.1-staging.65
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/server.js +65 -3
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -409,13 +409,14 @@ const approvals = new ApprovalManager();
|
|
|
409
409
|
const questionManager = new QuestionManager();
|
|
410
410
|
questionManager.onRequest((request) => {
|
|
411
411
|
scheduleAdvertise();
|
|
412
|
-
broadcast({ type: "session.question", sessionId: request.sessionId, requestId: request.id, questions: request.questions });
|
|
412
|
+
broadcast({ type: "session.question", sessionId: request.sessionId, requestId: request.id, questions: request.questions, createdAt: request.createdAt });
|
|
413
413
|
// Notify unconditionally: a clarifying question always fires mid-turn (the
|
|
414
414
|
// session is "working"), and it's a hard blocker the user must see to unblock
|
|
415
415
|
// — matching the pre-refactor behavior.
|
|
416
416
|
void sendNotificationHint({
|
|
417
417
|
kind: "question_asked",
|
|
418
418
|
sessionId: request.sessionId,
|
|
419
|
+
attentionId: request.id,
|
|
419
420
|
title: "Bivy needs your input",
|
|
420
421
|
body: `${sessionNotifyLabel(resolveSession(request.sessionId))} is asking a question — tap to answer.`,
|
|
421
422
|
});
|
|
@@ -3618,6 +3619,18 @@ function startRelayIfConfigured() {
|
|
|
3618
3619
|
clearInterval(advertiseResyncTimer);
|
|
3619
3620
|
advertiseResyncTimer = setInterval(() => scheduleAdvertise(), 60_000);
|
|
3620
3621
|
advertiseResyncTimer.unref?.();
|
|
3622
|
+
// Periodic online heartbeat. The relay flips the node's `online` flag
|
|
3623
|
+
// fire-and-forget on socket connect/close with no ordering guard, so a
|
|
3624
|
+
// late/racing `false` can pin a genuinely-connected node offline in the
|
|
3625
|
+
// registry until some later reconnect wins. Re-affirming online on a steady
|
|
3626
|
+
// interval keeps `last_seen_at` fresh and self-heals a lost race (the control
|
|
3627
|
+
// plane treats a recent heartbeat as online — see NODE_ONLINE_TTL_MS). Fire one
|
|
3628
|
+
// immediately so a reconnect corrects a stale `false` without waiting a full tick.
|
|
3629
|
+
if (nodeHeartbeatTimer)
|
|
3630
|
+
clearInterval(nodeHeartbeatTimer);
|
|
3631
|
+
void sendNodeHeartbeat();
|
|
3632
|
+
nodeHeartbeatTimer = setInterval(() => void sendNodeHeartbeat(), NODE_HEARTBEAT_MS);
|
|
3633
|
+
nodeHeartbeatTimer.unref?.();
|
|
3621
3634
|
}
|
|
3622
3635
|
console.log("[relay] connector enabled");
|
|
3623
3636
|
return true;
|
|
@@ -4100,6 +4113,28 @@ function startModelAuthWatcher() {
|
|
|
4100
4113
|
let sessionAdvertiseTarget;
|
|
4101
4114
|
let advertiseTimer;
|
|
4102
4115
|
let advertiseResyncTimer;
|
|
4116
|
+
// How often the node re-affirms it's online to the control plane. Kept well
|
|
4117
|
+
// under the control plane's NODE_ONLINE_TTL_MS (90s) so a missed beat or two
|
|
4118
|
+
// doesn't flap a healthy node's status.
|
|
4119
|
+
const NODE_HEARTBEAT_MS = 30_000;
|
|
4120
|
+
let nodeHeartbeatTimer;
|
|
4121
|
+
/** Re-affirm this node's online status so the registry self-heals a lost
|
|
4122
|
+
* relay connect/close race (see the heartbeat wiring in the relay connector
|
|
4123
|
+
* and NODE_ONLINE_TTL_MS in the control plane). Best-effort: a missed beat is
|
|
4124
|
+
* covered by the next tick and the TTL window. */
|
|
4125
|
+
async function sendNodeHeartbeat() {
|
|
4126
|
+
if (!sessionAdvertiseTarget)
|
|
4127
|
+
return;
|
|
4128
|
+
try {
|
|
4129
|
+
await fetch(`${sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "")}/node/heartbeat`, {
|
|
4130
|
+
method: "POST",
|
|
4131
|
+
headers: { authorization: `Bearer ${sessionAdvertiseTarget.enrollmentToken}` },
|
|
4132
|
+
});
|
|
4133
|
+
}
|
|
4134
|
+
catch {
|
|
4135
|
+
// best effort; the next tick retries
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
4103
4138
|
async function advertiseNodeName(name, prevName) {
|
|
4104
4139
|
if (!sessionAdvertiseTarget)
|
|
4105
4140
|
return;
|
|
@@ -4286,16 +4321,37 @@ async function advertiseSessions() {
|
|
|
4286
4321
|
// dropped; and with the remote flag off the map is empty, so the payload is
|
|
4287
4322
|
// byte-identical to before.
|
|
4288
4323
|
const agentServiceAddress = sessionAgentServiceAddress(record) ?? (record ? undefined : (await inMemorySessionLocations.lookup(s.id).catch(() => undefined))?.agentServiceAddress);
|
|
4324
|
+
const approvalAttention = approvals.list()
|
|
4325
|
+
.filter((a) => a.sessionId === s.id && a.status === "pending")
|
|
4326
|
+
.map((a) => ({
|
|
4327
|
+
id: a.id,
|
|
4328
|
+
kind: "approval",
|
|
4329
|
+
severity: a.risk === "critical" ? "critical" : a.risk === "high" ? "error" : "warning",
|
|
4330
|
+
createdAt: new Date(a.createdAt).toISOString(),
|
|
4331
|
+
}));
|
|
4332
|
+
const questionAttention = questionManager.list()
|
|
4333
|
+
.filter((q) => q.sessionId === s.id && q.status === "pending")
|
|
4334
|
+
.map((q) => ({ id: q.id, kind: "question", severity: "warning", createdAt: new Date(q.createdAt).toISOString() }));
|
|
4335
|
+
const failureAt = record?.lastFailureAt || (meta?.status === "failed" ? Date.parse(meta.updatedAt) : 0);
|
|
4336
|
+
const failureAttention = failureAt
|
|
4337
|
+
? [{
|
|
4338
|
+
id: "last-failure",
|
|
4339
|
+
kind: (record?.source || meta?.source ? "automation" : "session"),
|
|
4340
|
+
severity: "error",
|
|
4341
|
+
createdAt: new Date(failureAt).toISOString(),
|
|
4342
|
+
}]
|
|
4343
|
+
: [];
|
|
4289
4344
|
return {
|
|
4290
4345
|
sessionId: s.id,
|
|
4291
|
-
status: pendingApproval ? "needs_action" : (record ? (sessionBusy(record) ? "working" : "idle") : "saved"),
|
|
4292
|
-
needsAction: pendingApproval,
|
|
4346
|
+
status: pendingApproval || failureAttention.length ? "needs_action" : (record ? (sessionBusy(record) ? "working" : "idle") : "saved"),
|
|
4347
|
+
needsAction: pendingApproval || failureAttention.length > 0,
|
|
4293
4348
|
source: record?.source || meta?.source,
|
|
4294
4349
|
titleEnc: name ? relay.sealString(name) : undefined,
|
|
4295
4350
|
branch: record?.worktree?.branch || meta?.branch,
|
|
4296
4351
|
agentServiceAddress,
|
|
4297
4352
|
githubIssueUrl: record?.githubIssueUrl,
|
|
4298
4353
|
prUrl: record?.prUrl,
|
|
4354
|
+
attention: [...approvalAttention, ...questionAttention, ...failureAttention],
|
|
4299
4355
|
};
|
|
4300
4356
|
}));
|
|
4301
4357
|
try {
|
|
@@ -6578,6 +6634,8 @@ function markSessionWorking(record, activity) {
|
|
|
6578
6634
|
record.isWorking = true;
|
|
6579
6635
|
record.lastActivity = activity;
|
|
6580
6636
|
record.workingStartedAt ||= Date.now();
|
|
6637
|
+
// A new attempt resolves the prior turn's failure condition at its source.
|
|
6638
|
+
record.lastFailureAt = undefined;
|
|
6581
6639
|
metadata.touchSession(record.id, "working");
|
|
6582
6640
|
if (!wasWorking)
|
|
6583
6641
|
scheduleAdvertise(); // idle → working transition
|
|
@@ -6789,6 +6847,9 @@ function attachSessionListeners(record) {
|
|
|
6789
6847
|
});
|
|
6790
6848
|
}
|
|
6791
6849
|
else if (turnError) {
|
|
6850
|
+
record.lastFailureAt = Date.now();
|
|
6851
|
+
metadata.touchSession(record.id, "failed");
|
|
6852
|
+
scheduleAdvertise();
|
|
6792
6853
|
broadcast({ type: "session.error", sessionId: record.id, error: turnError });
|
|
6793
6854
|
void sendNotificationHint({
|
|
6794
6855
|
kind: "session_error",
|
|
@@ -7949,6 +8010,7 @@ approvals.onRequest((request) => {
|
|
|
7949
8010
|
void sendNotificationHint({
|
|
7950
8011
|
kind: "approval_requested",
|
|
7951
8012
|
sessionId: request.sessionId,
|
|
8013
|
+
attentionId: request.id,
|
|
7952
8014
|
title: "Approval needed",
|
|
7953
8015
|
body: `${sessionNotifyLabel(rec)} wants to run something — tap to approve or deny.`,
|
|
7954
8016
|
});
|
package/package.json
CHANGED