@kingcrab/pi-imessage 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/error-log.js +37 -0
- package/dist/main.js +15 -4
- package/dist/web/app.js +118 -53
- package/dist/web/server.js +11 -0
- package/dist/web/style.css +6 -0
- package/dist/web/tasks.js +120 -12
- package/docs/adr/0053-service-log-errors.md +44 -0
- package/docs/adr/README.md +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const REASON_LIMIT = 200;
|
|
2
|
+
const failedAssistant = (message) => message.role === "assistant" && message.stopReason === "error";
|
|
3
|
+
/**
|
|
4
|
+
* Service-log error lines for failures that break normal operation (ADR 0053). Durable keeps a failed
|
|
5
|
+
* task only in its records, so without this an exhausted provider quota leaves stdout silent while the
|
|
6
|
+
* process, port and HTTP checks look healthy. Each error kind is one branch here; the first is a model
|
|
7
|
+
* call that ends failed, which leaves the chat or scheduled job without the answer it was waiting for.
|
|
8
|
+
*/
|
|
9
|
+
export function logErrors(harness) {
|
|
10
|
+
return harness.subscribeCommits(({ changes }) => {
|
|
11
|
+
for (const change of changes) {
|
|
12
|
+
if (change.type !== "task" || change.value.kind !== "pi.generation")
|
|
13
|
+
continue;
|
|
14
|
+
const task = change.value;
|
|
15
|
+
if (task.state.status !== "terminal")
|
|
16
|
+
continue;
|
|
17
|
+
const { outcome } = task.state;
|
|
18
|
+
if (outcome.status === "completed" || outcome.status === "aborted")
|
|
19
|
+
continue;
|
|
20
|
+
// The failing assistant message is committed together with the task and names provider and model.
|
|
21
|
+
const failed = changes
|
|
22
|
+
.flatMap((item) => item.type === "entry" && item.value.conversationId === task.conversationId
|
|
23
|
+
? (item.value.model ?? [])
|
|
24
|
+
: [])
|
|
25
|
+
.find(failedAssistant);
|
|
26
|
+
console.error(new Date().toISOString(), "Model call failed", {
|
|
27
|
+
kind: task.kind,
|
|
28
|
+
task: task.id,
|
|
29
|
+
conversation: task.conversationId,
|
|
30
|
+
status: outcome.status,
|
|
31
|
+
provider: failed?.provider,
|
|
32
|
+
model: failed?.model,
|
|
33
|
+
reason: (outcome.error?.message ?? outcome.reason ?? "").slice(0, REASON_LIMIT),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -7,6 +7,7 @@ import { compactionReply, isCommand, runCommand } from "./agent/commands.js";
|
|
|
7
7
|
import { compactChats } from "./agent/compaction.js";
|
|
8
8
|
import { deliverReplies, recoverSending } from "./agent/deliver.js";
|
|
9
9
|
import { deliverDirect } from "./agent/direct-send.js";
|
|
10
|
+
import { logErrors } from "./agent/error-log.js";
|
|
10
11
|
import { openHarness } from "./agent/harness.js";
|
|
11
12
|
import { submitHealth } from "./agent/health.js";
|
|
12
13
|
import { openModels, readDefaults, requestSettings, withModelPolicy } from "./agent/models.js";
|
|
@@ -105,6 +106,7 @@ reloadHttpTransport) {
|
|
|
105
106
|
const { harness, storage } = owner;
|
|
106
107
|
const scheduling = schedulingExtension(harness, defaults);
|
|
107
108
|
let progress;
|
|
109
|
+
const stopErrorLog = logErrors(harness);
|
|
108
110
|
try {
|
|
109
111
|
// The native owner lock is acquired before cleaning up browsers left by a previous process.
|
|
110
112
|
await browser.close();
|
|
@@ -394,6 +396,7 @@ reloadHttpTransport) {
|
|
|
394
396
|
closing ??= (async () => {
|
|
395
397
|
await Promise.allSettled([...operations]);
|
|
396
398
|
await progress.close();
|
|
399
|
+
stopErrorLog();
|
|
397
400
|
await owner.close(browser.close);
|
|
398
401
|
})();
|
|
399
402
|
return closing;
|
|
@@ -446,10 +449,15 @@ export async function startMessaging(options, reloadHttpTransport) {
|
|
|
446
449
|
let closing;
|
|
447
450
|
let timer;
|
|
448
451
|
let started = false;
|
|
452
|
+
/** Rows committed before the call are read even when a poll is already in flight. */
|
|
449
453
|
function poll() {
|
|
450
454
|
if (closed)
|
|
451
455
|
return Promise.reject(new Error("Messaging is closed"));
|
|
452
|
-
|
|
456
|
+
const previous = pending?.catch(() => undefined) ?? Promise.resolve();
|
|
457
|
+
const current = previous
|
|
458
|
+
.then(async () => {
|
|
459
|
+
if (closed)
|
|
460
|
+
return;
|
|
453
461
|
try {
|
|
454
462
|
await watcher.poll();
|
|
455
463
|
}
|
|
@@ -458,10 +466,13 @@ export async function startMessaging(options, reloadHttpTransport) {
|
|
|
458
466
|
if (!closed)
|
|
459
467
|
await service.deliver();
|
|
460
468
|
}
|
|
461
|
-
})
|
|
462
|
-
|
|
469
|
+
})
|
|
470
|
+
.finally(() => {
|
|
471
|
+
if (pending === current)
|
|
472
|
+
pending = undefined;
|
|
463
473
|
});
|
|
464
|
-
|
|
474
|
+
pending = current;
|
|
475
|
+
return current;
|
|
465
476
|
}
|
|
466
477
|
function tick() {
|
|
467
478
|
void poll()
|
package/dist/web/app.js
CHANGED
|
@@ -615,7 +615,6 @@ function renderScheduled() {
|
|
|
615
615
|
|
|
616
616
|
function renderTasks() {
|
|
617
617
|
view.replaceChildren();
|
|
618
|
-
const children = new Map();
|
|
619
618
|
const conversations = new Map(data.conversations.map((conversation) => [conversation.id, conversation]));
|
|
620
619
|
const chatNames = new Map(
|
|
621
620
|
(data.chats?.items || []).map((chat) => [
|
|
@@ -623,70 +622,130 @@ function renderTasks() {
|
|
|
623
622
|
chatName({ ...chat, history: { groupName: conversations.get(chat.conversationId)?.groupName } }, []),
|
|
624
623
|
]),
|
|
625
624
|
);
|
|
626
|
-
const conversationNames = new Map(chatNames);
|
|
627
625
|
const scheduleNames = new Map((data.schedules?.items || []).map((job) => [job.conversationId, job.name]));
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
626
|
+
const sessionNames = new Map(
|
|
627
|
+
(data.sessions?.items || []).map((session) => [session.conversationId, session.label]),
|
|
628
|
+
);
|
|
629
|
+
// Every conversation is named by the session document that created it, or by the task that owns it.
|
|
630
|
+
function source(id) {
|
|
631
|
+
if (chatNames.has(id)) return { name: `chat ${chatNames.get(id)}`, doc: "imessage.chats" };
|
|
632
|
+
if (scheduleNames.has(id))
|
|
633
|
+
return { name: `schedule ${scheduleNames.get(id)}`, doc: "imessage.schedules" };
|
|
634
|
+
if (sessionNames.has(id)) return { name: `session ${sessionNames.get(id)}`, doc: "imessage.sessions" };
|
|
636
635
|
const owner = conversations.get(id)?.owner;
|
|
637
|
-
|
|
638
|
-
const parentName = conversationName(owner.conversationId);
|
|
639
|
-
return parentName ? `${parentName} / subagent` : "subagent";
|
|
636
|
+
return owner ? { name: "subagent", doc: "owned by a tool" } : { name: "", doc: "" };
|
|
640
637
|
}
|
|
641
638
|
function belongsToChat(id) {
|
|
642
639
|
if (chatNames.has(id) || scheduleNames.has(id)) return true;
|
|
643
640
|
const owner = conversations.get(id)?.owner;
|
|
644
641
|
return owner ? belongsToChat(owner.conversationId) : false;
|
|
645
642
|
}
|
|
646
|
-
|
|
647
|
-
|
|
643
|
+
// Children by storage relation: conversations under their owner task, tasks under their owner task or
|
|
644
|
+
// conversation, entries under the task that wrote them (byTaskId). A pi.user entry opens a turn: the
|
|
645
|
+
// later root tasks and loose entries of its conversation nest under it until the next one.
|
|
646
|
+
const children = new Map();
|
|
647
|
+
const add = (key, node) => {
|
|
648
648
|
if (!children.has(key)) children.set(key, []);
|
|
649
|
-
children.get(key).push(
|
|
649
|
+
children.get(key).push(node);
|
|
650
|
+
};
|
|
651
|
+
const taskIds = new Set(data.tasks.map((task) => task.id));
|
|
652
|
+
for (const conversation of data.conversations) {
|
|
653
|
+
if (conversation.owner)
|
|
654
|
+
add(`task-${conversation.owner.taskId}`, { type: "conversation", ...conversation });
|
|
655
|
+
}
|
|
656
|
+
const turns = new Map();
|
|
657
|
+
for (const entry of data.entries || []) {
|
|
658
|
+
if (entry.kind !== "pi.user") continue;
|
|
659
|
+
if (!turns.has(entry.conversationId)) turns.set(entry.conversationId, []);
|
|
660
|
+
turns.get(entry.conversationId).push(entry.id);
|
|
661
|
+
}
|
|
662
|
+
// Entries arrive newest first; a turn lookup needs ascending ids.
|
|
663
|
+
for (const ids of turns.values()) ids.sort((left, right) => left - right);
|
|
664
|
+
function turnKey(conversationId, id) {
|
|
665
|
+
const opened = (turns.get(conversationId) || []).filter((turn) => turn < id).at(-1);
|
|
666
|
+
return opened === undefined ? `conversation-${conversationId}` : `entry-${opened}`;
|
|
650
667
|
}
|
|
651
668
|
for (const task of data.tasks) {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
669
|
+
add(task.owner === undefined ? turnKey(task.conversationId, task.id) : `task-${task.owner}`, {
|
|
670
|
+
type: "task",
|
|
671
|
+
...task,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
for (const entry of data.entries || []) {
|
|
675
|
+
if (entry.kind === "pi.user") add(`conversation-${entry.conversationId}`, { type: "entry", ...entry });
|
|
676
|
+
else if (entry.byTaskId !== undefined && taskIds.has(entry.byTaskId))
|
|
677
|
+
add(`task-${entry.byTaskId}`, { type: "entry", ...entry });
|
|
678
|
+
else add(turnKey(entry.conversationId, entry.id), { type: "entry", ...entry });
|
|
679
|
+
}
|
|
680
|
+
// Newest turn first in a card; inside a turn the chain reads in storage order. Cards are sorted by activity.
|
|
681
|
+
for (const [key, nodes] of children) {
|
|
682
|
+
if (key.startsWith("conversation-")) nodes.sort((left, right) => right.id - left.id);
|
|
683
|
+
else nodes.sort((left, right) => left.id - right.id);
|
|
684
|
+
}
|
|
685
|
+
function lazyDetails(summary, url) {
|
|
686
|
+
const detail = element("details");
|
|
687
|
+
const content = element("pre", "Loading latest state...");
|
|
688
|
+
detail.append(summary, content);
|
|
689
|
+
let loaded = false;
|
|
690
|
+
detail.addEventListener("toggle", async () => {
|
|
691
|
+
if (!detail.open || loaded) return;
|
|
692
|
+
loaded = true;
|
|
693
|
+
try {
|
|
694
|
+
content.textContent = JSON.stringify(await fetchData(url), null, 2);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
loaded = false;
|
|
697
|
+
content.textContent = error.message;
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
return detail;
|
|
701
|
+
}
|
|
702
|
+
function taskSummary(node) {
|
|
703
|
+
const label =
|
|
704
|
+
node.kind === "pi.generation"
|
|
705
|
+
? "Generation"
|
|
706
|
+
: node.kind === "pi.tool"
|
|
707
|
+
? `Tool ${node.name || "?"}`
|
|
708
|
+
: node.kind;
|
|
709
|
+
const summary = element("summary", label);
|
|
710
|
+
const state = node.outcome ? [node.outcome] : [node.status, node.phase].filter(Boolean);
|
|
711
|
+
const waiting = node.on?.length ? ` on ${node.on.length} task${node.on.length === 1 ? "" : "s"}` : "";
|
|
712
|
+
const sleeping =
|
|
713
|
+
typeof node.until === "number" ? ` until ${new Date(node.until).toLocaleString("sv-SE")}` : "";
|
|
714
|
+
const flags = `${node.background ? " [background]" : ""}${node.abortRequested ? " [abort requested]" : ""}`;
|
|
715
|
+
summary.append(element("span", ` [${state.join(" / ")}]${waiting}${sleeping}${flags}`, "meta"));
|
|
716
|
+
return summary;
|
|
717
|
+
}
|
|
718
|
+
function entrySummary(node) {
|
|
719
|
+
const summary = element("summary", node.kind.replace(/^pi\./, ""));
|
|
720
|
+
if (node.text) summary.append(element("span", ` "${node.text}"`, "entry-text"));
|
|
721
|
+
return summary;
|
|
655
722
|
}
|
|
656
723
|
function renderNode(node) {
|
|
657
724
|
const item = element("li");
|
|
658
725
|
const key = `${node.type}-${node.id}`;
|
|
659
726
|
item.id = `tree-${key}`;
|
|
660
727
|
if (node.type === "conversation") {
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
if (
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
728
|
+
const { name, doc } = source(node.id);
|
|
729
|
+
// A card is one conversation; a conversation owned by a task folds under it.
|
|
730
|
+
const heading = element(node.owner ? "summary" : "span", "Conversation");
|
|
731
|
+
if (name) heading.append(element("span", ` ${name}`));
|
|
732
|
+
if (doc) heading.append(element("span", ` <- ${doc}`, "meta"));
|
|
733
|
+
if (chatNames.has(node.id))
|
|
734
|
+
heading.append(element("span", " "), link("Chat", `/chat?conversationId=${node.id}`, "meta"));
|
|
735
|
+
if (node.owner) {
|
|
736
|
+
const detail = element("details");
|
|
737
|
+
detail.id = key;
|
|
738
|
+
detail.append(heading);
|
|
739
|
+
item.append(detail);
|
|
740
|
+
} else item.append(heading);
|
|
741
|
+
} else if (node.type === "task") {
|
|
742
|
+
const detail = lazyDetails(taskSummary(node), `/tasks/task/data?taskId=${node.id}`);
|
|
743
|
+
detail.id = key;
|
|
744
|
+
item.append(detail);
|
|
668
745
|
} else {
|
|
669
|
-
|
|
746
|
+
item.className = node.kind === "pi.user" ? "entry turn" : "entry";
|
|
747
|
+
const detail = lazyDetails(entrySummary(node), `/tasks/entry/data?entryId=${node.id}`);
|
|
670
748
|
detail.id = key;
|
|
671
|
-
const summary = element("summary", `${node.kind}${node.name ? `: ${node.name}` : ""} #${node.id}`);
|
|
672
|
-
const status = [node.status, node.phase, node.outcome].filter(Boolean).join(" / ");
|
|
673
|
-
const waiting = node.on?.length ? ` on [${node.on.join(", ")}]` : "";
|
|
674
|
-
const flags = `${node.background ? " [background]" : ""}${node.abortRequested ? " [abort requested]" : ""}`;
|
|
675
|
-
summary.append(element("span", ` ${status}${waiting}${flags}`, "meta"));
|
|
676
|
-
const content = element("pre", "Loading latest state...");
|
|
677
|
-
detail.append(summary, content);
|
|
678
|
-
let loaded = false;
|
|
679
|
-
detail.addEventListener("toggle", async () => {
|
|
680
|
-
if (!detail.open || loaded) return;
|
|
681
|
-
loaded = true;
|
|
682
|
-
try {
|
|
683
|
-
const task = await fetchData(`/tasks/task/data?taskId=${node.id}`);
|
|
684
|
-
content.textContent = JSON.stringify(task, null, 2);
|
|
685
|
-
} catch (error) {
|
|
686
|
-
loaded = false;
|
|
687
|
-
content.textContent = error.message;
|
|
688
|
-
}
|
|
689
|
-
});
|
|
690
749
|
item.append(detail);
|
|
691
750
|
}
|
|
692
751
|
const descendants = children.get(key) || [];
|
|
@@ -694,23 +753,29 @@ function renderTasks() {
|
|
|
694
753
|
if (descendants.length || more) {
|
|
695
754
|
const list = element("ul", undefined, "task-tree");
|
|
696
755
|
for (const child of descendants) list.append(renderNode(child));
|
|
697
|
-
if (more) list.append(element("li", "older
|
|
698
|
-
item.append(list);
|
|
756
|
+
if (more) list.append(element("li", "older turns not listed", "meta"));
|
|
757
|
+
(node.type === "conversation" && node.owner ? item.firstChild : item).append(list);
|
|
699
758
|
}
|
|
700
759
|
return item;
|
|
701
760
|
}
|
|
702
761
|
const roots = conversationId
|
|
703
|
-
? data.conversations
|
|
704
|
-
|
|
705
|
-
.map((conversation) => ({ type: "conversation", ...conversation }))
|
|
706
|
-
: children.get("root") || [];
|
|
762
|
+
? data.conversations.filter((conversation) => String(conversation.id) === conversationId)
|
|
763
|
+
: data.conversations.filter((conversation) => !conversation.owner);
|
|
707
764
|
const visible = roots.filter((conversation) => belongsToChat(conversation.id));
|
|
765
|
+
if (!conversationId && data.session)
|
|
766
|
+
view.append(
|
|
767
|
+
element(
|
|
768
|
+
"p",
|
|
769
|
+
`Session ${data.session} docs: imessage.chats, imessage.schedules, imessage.sessions`,
|
|
770
|
+
"muted",
|
|
771
|
+
),
|
|
772
|
+
);
|
|
708
773
|
if (!visible.length) view.append(element("p", "No conversations.", "empty"));
|
|
709
774
|
for (const conversation of visible.toSorted(compareConversations)) {
|
|
710
775
|
const card = element("section", undefined, "card");
|
|
711
776
|
card.id = `tasks-${conversation.id}`;
|
|
712
777
|
const list = element("ul", undefined, "task-tree task-root");
|
|
713
|
-
list.append(renderNode(conversation));
|
|
778
|
+
list.append(renderNode({ type: "conversation", ...conversation }));
|
|
714
779
|
card.append(list);
|
|
715
780
|
view.append(card);
|
|
716
781
|
}
|
package/dist/web/server.js
CHANGED
|
@@ -281,10 +281,21 @@ export async function startWeb(options) {
|
|
|
281
281
|
schedules: await options.agent.harness.snapshot(Schedules, BACKGROUND_CONTEXT),
|
|
282
282
|
chats: await options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
|
|
283
283
|
sessions: await options.agent.harness.snapshot(Sessions, BACKGROUND_CONTEXT),
|
|
284
|
+
session: join(options.workingDir, "durable"),
|
|
284
285
|
revision: viewRevision(),
|
|
285
286
|
};
|
|
286
287
|
compact = true;
|
|
287
288
|
}
|
|
289
|
+
else if (url.pathname === "/tasks/entry/data") {
|
|
290
|
+
const id = url.searchParams.get("entryId");
|
|
291
|
+
if (!id || !/^\d+$/.test(id) || !Number.isSafeInteger(Number(id)))
|
|
292
|
+
throw new Error("Invalid entry ID");
|
|
293
|
+
value = await options.agent.harness.commit((tx) => tx.entry(Number(id)), BACKGROUND_CONTEXT);
|
|
294
|
+
if (!value) {
|
|
295
|
+
await json(response, 404, { error: "Entry not found" });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
288
299
|
else if (url.pathname === "/tasks/task/data") {
|
|
289
300
|
const id = url.searchParams.get("taskId");
|
|
290
301
|
if (!id || !/^\d+$/.test(id) || !Number.isSafeInteger(Number(id)))
|
package/dist/web/style.css
CHANGED
|
@@ -439,6 +439,12 @@ body.page-logs .native-state {
|
|
|
439
439
|
padding: 8px 0 8px 16px;
|
|
440
440
|
}
|
|
441
441
|
|
|
442
|
+
/* Entries are history records, not work: all grey, no marker of their own. */
|
|
443
|
+
.task-tree li.entry > details > summary {
|
|
444
|
+
opacity: 0.6;
|
|
445
|
+
list-style: none;
|
|
446
|
+
}
|
|
447
|
+
|
|
442
448
|
.schedule-row {
|
|
443
449
|
background: var(--surface);
|
|
444
450
|
margin-bottom: 6px;
|
package/dist/web/tasks.js
CHANGED
|
@@ -1,33 +1,139 @@
|
|
|
1
1
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
2
|
+
import { UserEntry, } from "@earendil-works/pi-durable";
|
|
2
3
|
import { displayHistory, historyActivity } from "./history.js";
|
|
3
|
-
/** Newest
|
|
4
|
+
/** Newest generations shown per conversation, with every entry and task from the turn of the oldest one. */
|
|
5
|
+
export const RECENT_GENERATIONS_PER_CONVERSATION = 10;
|
|
6
|
+
/** Newest tasks shown for a conversation that never generated (schedulers). */
|
|
4
7
|
export const RECENT_TASKS_PER_CONVERSATION = 20;
|
|
8
|
+
/** Pages of 5 entries read back from the oldest generation to find the user message that opened its turn. */
|
|
9
|
+
const TURN_LOOKBACK_PAGES = 4;
|
|
10
|
+
function firstLine(text, limit = 80) {
|
|
11
|
+
return text
|
|
12
|
+
.replace(/^\[(?:Group '[^']*'|DM|SMS) from [^\]]+\] /, "")
|
|
13
|
+
.split("\n")
|
|
14
|
+
.find((line) => line.trim())
|
|
15
|
+
?.trim()
|
|
16
|
+
.slice(0, limit);
|
|
17
|
+
}
|
|
18
|
+
/** One line per entry: the first user or assistant message, tool calls by name only, tool results by text. */
|
|
19
|
+
function summarizeEntry(entry) {
|
|
20
|
+
const message = entry.model?.[0];
|
|
21
|
+
if (!message)
|
|
22
|
+
return { role: undefined, text: undefined };
|
|
23
|
+
const content = message.content;
|
|
24
|
+
const text = typeof content === "string"
|
|
25
|
+
? content
|
|
26
|
+
: content
|
|
27
|
+
.map((block) => block.type === "text"
|
|
28
|
+
? block.text
|
|
29
|
+
: block.type === "toolCall"
|
|
30
|
+
? `[toolCall ${block.name}]`
|
|
31
|
+
: block.type === "thinking"
|
|
32
|
+
? "[thinking]"
|
|
33
|
+
: `[${block.type}]`)
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join(" ");
|
|
36
|
+
return { role: message.role, text: firstLine(text) };
|
|
37
|
+
}
|
|
5
38
|
/**
|
|
6
39
|
* Read one consistent tree, newest first: all conversations, every unfinished task, every task that owns a
|
|
7
|
-
* conversation
|
|
8
|
-
*
|
|
40
|
+
* conversation and their parent chains. Per conversation the window is the newest generations plus every
|
|
41
|
+
* entry and task back to the `pi.user` entry that opened the oldest one's turn. Entries and tasks share one
|
|
42
|
+
* id sequence, so the window is a plain id range and the browser nests records by `owner` and `byTaskId`
|
|
43
|
+
* without any inference. Descending scans stop at the window instead of reading the whole history
|
|
44
|
+
* (earendil-works/pi#10546).
|
|
9
45
|
*/
|
|
10
46
|
export function readTaskTree(harness) {
|
|
11
47
|
return harness.commit(async (tx) => {
|
|
12
48
|
const conversations = [];
|
|
13
49
|
const kept = new Map();
|
|
50
|
+
const entries = [];
|
|
14
51
|
const more = {};
|
|
15
52
|
let cursor;
|
|
16
53
|
do {
|
|
17
54
|
const page = await tx.scanConversations({}, 100, cursor);
|
|
18
55
|
for (const conversation of page.items) {
|
|
19
|
-
const
|
|
20
|
-
|
|
56
|
+
const id = conversation.id;
|
|
57
|
+
// One descending scan: the newest tasks down to the oldest shown generation; a task scan with a
|
|
58
|
+
// conversation filter walks the global task order, so it must not run twice per conversation.
|
|
59
|
+
const recent = [];
|
|
60
|
+
let oldest;
|
|
61
|
+
let generations = 0;
|
|
62
|
+
let next;
|
|
63
|
+
scan: do {
|
|
64
|
+
const tasks = await tx.scanTasks({ conversationId: id, order: "descending" }, 100, next);
|
|
65
|
+
for (const task of tasks.items) {
|
|
66
|
+
if (task.kind === "pi.generation") {
|
|
67
|
+
if (++generations > RECENT_GENERATIONS_PER_CONVERSATION) {
|
|
68
|
+
more[id] = true;
|
|
69
|
+
break scan;
|
|
70
|
+
}
|
|
71
|
+
oldest = task;
|
|
72
|
+
}
|
|
73
|
+
else if (!generations && recent.length >= RECENT_TASKS_PER_CONVERSATION) {
|
|
74
|
+
more[id] = true;
|
|
75
|
+
break scan;
|
|
76
|
+
}
|
|
77
|
+
recent.push(task);
|
|
78
|
+
}
|
|
79
|
+
next = tasks.next;
|
|
80
|
+
} while (next);
|
|
81
|
+
let history;
|
|
82
|
+
let floor;
|
|
83
|
+
if (oldest) {
|
|
84
|
+
history = [];
|
|
85
|
+
let next;
|
|
86
|
+
do {
|
|
87
|
+
const window = await tx.scanEntries({ conversationId: id, minEntryId: oldest.id }, 100, next);
|
|
88
|
+
history.push(...window.items);
|
|
89
|
+
next = window.next;
|
|
90
|
+
} while (next);
|
|
91
|
+
// Walk back to the user message that opened the oldest generation's turn.
|
|
92
|
+
let max = oldest.id - 1;
|
|
93
|
+
for (let pages = 0; pages < TURN_LOOKBACK_PAGES; pages++) {
|
|
94
|
+
const older = await tx.scanEntries({ conversationId: id, maxEntryId: max }, 5);
|
|
95
|
+
const user = older.items.findIndex((entry) => UserEntry.is(entry));
|
|
96
|
+
history.push(...(user === -1 ? older.items : older.items.slice(0, user + 1)));
|
|
97
|
+
if (user !== -1 || !older.items.length)
|
|
98
|
+
break;
|
|
99
|
+
max = older.items[older.items.length - 1].id - 1;
|
|
100
|
+
}
|
|
101
|
+
const turn = history.find((entry) => UserEntry.is(entry) && entry.id < oldest.id);
|
|
102
|
+
floor = turn ? turn.id : oldest.id;
|
|
103
|
+
history = history.filter((entry) => entry.id >= floor);
|
|
104
|
+
// Tasks between the turn's user message and its first generation sit on the next page.
|
|
105
|
+
while (next && (recent.at(-1)?.id ?? 0) >= floor) {
|
|
106
|
+
const tasks = await tx.scanTasks({ conversationId: id, order: "descending" }, 100, next);
|
|
107
|
+
recent.push(...tasks.items.filter((task) => task.id >= floor));
|
|
108
|
+
next = tasks.next;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
// Names and activity still come from the newest 100 entries, as on the Messages page.
|
|
113
|
+
history = [...(await tx.scanEntries({ conversationId: id }, 100)).items];
|
|
114
|
+
floor = history[RECENT_TASKS_PER_CONVERSATION - 1]?.id ?? 0;
|
|
115
|
+
}
|
|
116
|
+
const page = { items: history, next: undefined };
|
|
117
|
+
const { groupName } = displayHistory(page, 1);
|
|
21
118
|
conversations.push({
|
|
22
119
|
...conversation,
|
|
23
|
-
updatedAt: historyActivity(
|
|
120
|
+
updatedAt: historyActivity(page),
|
|
24
121
|
groupName: typeof groupName === "string" ? groupName : undefined,
|
|
25
122
|
});
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
123
|
+
for (const entry of history) {
|
|
124
|
+
if (entry.id < floor)
|
|
125
|
+
continue;
|
|
126
|
+
entries.push({
|
|
127
|
+
id: entry.id,
|
|
128
|
+
conversationId: id,
|
|
129
|
+
kind: entry.kind,
|
|
130
|
+
byTaskId: entry.byTaskId,
|
|
131
|
+
...summarizeEntry(entry),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
for (const task of recent)
|
|
135
|
+
if (!oldest || task.id >= floor)
|
|
136
|
+
kept.set(task.id, task);
|
|
31
137
|
if (conversation.owner && !kept.has(conversation.owner.taskId)) {
|
|
32
138
|
const owner = await tx.task(conversation.owner.taskId);
|
|
33
139
|
if (owner)
|
|
@@ -85,13 +191,15 @@ export function readTaskTree(harness) {
|
|
|
85
191
|
background: task.background,
|
|
86
192
|
abortRequested: task.abortRequested,
|
|
87
193
|
status: state.status,
|
|
194
|
+
// Phases exist only while a task runs; a terminal record keeps its outcome and drops the checkpoint.
|
|
88
195
|
phase: state.checkpoint && typeof state.checkpoint === "object" && !Array.isArray(state.checkpoint)
|
|
89
196
|
? state.checkpoint.phase
|
|
90
197
|
: undefined,
|
|
91
198
|
on: state.status === "waiting" ? state.on : undefined,
|
|
199
|
+
until: state.status === "sleeping" ? state.until : undefined,
|
|
92
200
|
outcome: state.outcome?.status,
|
|
93
201
|
});
|
|
94
202
|
}
|
|
95
|
-
return { conversations, tasks, more };
|
|
203
|
+
return { conversations, tasks, entries, more };
|
|
96
204
|
}, BACKGROUND_CONTEXT);
|
|
97
205
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# 0053. Service-log errors for failures that break normal operation
|
|
2
|
+
|
|
3
|
+
Status: proposed
|
|
4
|
+
|
|
5
|
+
**Problem.** Durable records every task outcome, but only in its storage.
|
|
6
|
+
`service.log` shows startup, sends and a few delivery warnings. On 2026-10-25
|
|
7
|
+
the nightly memory reflection failed three times because the model child had
|
|
8
|
+
exhausted a provider quota; the process, the port and the root HTTP check
|
|
9
|
+
looked healthy and `service.log` had no line about it. The failure was only
|
|
10
|
+
visible by reading `durable/main.jsonl` or by a chat notice that was deduped.
|
|
11
|
+
|
|
12
|
+
**Example.**
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
2026-10-25T00:14:48.924Z Model call failed {
|
|
16
|
+
kind: 'pi.generation', task: 183434, conversation: 183426, status: 'failed',
|
|
17
|
+
provider: 'openai-codex', model: 'gpt-6.1-sol',
|
|
18
|
+
reason: 'You have hit your ChatGPT usage limit (plus plan). Try again in ~291 min.'
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Decision.**
|
|
23
|
+
|
|
24
|
+
- `service.log` gets a `console.error` line with an ISO timestamp for a failure
|
|
25
|
+
that breaks normal operation. A failure qualifies when either holds: a chat
|
|
26
|
+
or scheduled job does not get the result it was waiting for, or the system
|
|
27
|
+
enters a state that needs an operator. Transient errors that recover on
|
|
28
|
+
their own, such as a retried provider error or a delivery whose status is
|
|
29
|
+
later confirmed, do not qualify.
|
|
30
|
+
- One module, `src/agent/error-log.ts`, owns these lines. It observes Durable
|
|
31
|
+
commits synchronously and never calls Session APIs. Each error kind is one
|
|
32
|
+
branch with its own message name; adding a kind states which of the two
|
|
33
|
+
conditions it meets.
|
|
34
|
+
- The first kind is a model call that ends `failed` or `faulted`: the line
|
|
35
|
+
carries the task, conversation, provider, model and the reason truncated to
|
|
36
|
+
200 characters. It carries no prompt, tool argument or input content.
|
|
37
|
+
- Other task kinds, including scheduled jobs that fault, are not logged yet;
|
|
38
|
+
they are added under the same conditions when needed.
|
|
39
|
+
|
|
40
|
+
**Consequences.** A quota, auth or provider outage is visible in the service
|
|
41
|
+
log within the same commit that records it, so a health check that reads the
|
|
42
|
+
log catches it without a paid model probe. The web `/logs` page shows the same
|
|
43
|
+
lines. Durable records remain the full source of truth; the log is a pointer
|
|
44
|
+
to them, not a replacement.
|
package/docs/adr/README.md
CHANGED
|
@@ -34,6 +34,7 @@ Accepted ADR files are never edited; this index records which ones are current.
|
|
|
34
34
|
| [0048](0048-quiet-attachment-reads.md) | Quiet attachment reads | Proposed; owner-requested implementation in #38 |
|
|
35
35
|
| [0049](0049-per-chat-progress-notices.md) | Per-chat progress notices via `/verbose` | Proposed; owner-requested implementation |
|
|
36
36
|
| [0052](0052-host-owned-self-restart.md) | The host owns its own restart (`restart_service`) | Active; owner accepted on 2026-10-24 (#39) |
|
|
37
|
+
| [0053](0053-service-log-errors.md) | Service-log errors for failures that break normal operation | Proposed; first kind (model call failed) implemented |
|
|
37
38
|
|
|
38
39
|
## Replaced or rejected
|
|
39
40
|
|