mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(p2): switch /tree branches while busy; subagents report to the asking branch
- Switching branches (navigate, select) no longer waits for the main conversation: a branch left while running keeps working in the background, read-only with Stop allowed. - Branch rows in the sidebar show the working dot; background branches' answers count as unread. - Fence refusals name a running background branch instead of a subagent #N. - Subagent reports go to the conversation that sent the message; team send to "parent" goes to the conversation that last spawned or messaged the subagent.
This commit is contained in:
parent
4d72d05bcd
commit
0c5b7ee393
9 changed files with 118 additions and 49 deletions
|
|
@ -40,13 +40,15 @@ version 2; absent: `{ 1, [{ 1, null }] }`):
|
|||
list, `Sessions.fork`) uses `isTopLevel`, a document read every tool (`api`) and section (`input.read`) can do.
|
||||
- **Current main** = `conversation`. It answers "which top-level conversation is shown and followed": the session row
|
||||
(`main`; model and thinking level follow it), the web home route, the drawer, the chat's back links, TUI checks,
|
||||
terminals, unread answers, `Sessions.fork`, peer messages (the conversations broker), and where subagents report.
|
||||
terminals, reading unread answers, `Sessions.fork`, and peer messages (the conversations broker).
|
||||
- Clients learn it through the session row (`Sessions` version 4, catalog schema 4; version 5: null labels); the
|
||||
worker reports it after a switch and at every start (`treeSelf`). Clients built before still bind: the providers
|
||||
accept `Sessions` 3 and 4 and `SessionTree` 1, mapping rows and the tree back (no `main`/`branches` for 3, a null
|
||||
label as the session title for 4, no labels for `SessionTree` 1). A web page or TUI viewing the previous main conversation follows a switch.
|
||||
- Earlier branches are read-only (`ReadOnlyConversations`): only the main conversation runs. The web shows only the
|
||||
main conversation and subagents (others redirect to the main one).
|
||||
- Earlier branches are read-only (`ReadOnlyConversations`): only the main conversation takes input (send, dequeue,
|
||||
configure); Stop stays allowed. A branch left while it works keeps running in the background: its run and queued
|
||||
input go on, its sidebar row shows the working dot, and its answer counts as unread. To watch or stop it, switch
|
||||
back to it. The web shows only the main conversation and subagents (others redirect to the main one).
|
||||
- **Labels** are stored per branch (see [Labels](#labels)).
|
||||
|
||||
## 2. Per-agent state
|
||||
|
|
@ -55,7 +57,7 @@ The main agent's state stays on conversation 1, whichever branch it works in; a
|
|||
|
||||
| Document | Where |
|
||||
| --- | --- |
|
||||
| `p2.subagents` | Conversation 1, as before. Subagents are session-wide; their reports and `team send to: "parent"` go to the current main. TODO on `Subagents`: should probably become session-scoped (a migration). |
|
||||
| `p2.subagents` | Conversation 1, as before. Subagents are session-wide. A subagent's report goes to the top-level conversation that sent the message (reporter input `parent`), and `team send to: "parent"` to the one that last spawned it or sent it a message (`Subagent.parent`), even after a `/tree` switch; records from before fall back to the current main. TODO on `Subagents`: should probably become session-scoped (a migration). |
|
||||
| `p2.location-move` | Conversation 1: the move belongs to the session, whichever top-level conversation asked. |
|
||||
| `p2.artifacts`, `voice.mode`, `chat-mode.settings` | `agentConversation(id)`: conversation 1 for a top-level conversation, the conversation itself for a subagent or one-shot agent. No copying at switches, no migration. |
|
||||
|
||||
|
|
@ -77,8 +79,10 @@ A branch forked before a subagent was spawned still lists that subagent (accepte
|
|||
Where the session continues: the top-level conversation whose history ends there (the main one first; switching back
|
||||
moves only `p2.main`), else a fork of the conversation storing that entry (same history, shorter chain than forking
|
||||
the one it was picked in), else, before the first message, a new ownerless conversation with the agent as it was then.
|
||||
The new conversation, its label and `p2.main` are one commit. Refused while the current main has a run or queued input;
|
||||
subagents keep running.
|
||||
The new conversation, its label and `p2.main` are one commit. Allowed while the current main has a run or queued
|
||||
input: that work goes on in the branch left (see Top-level conversations), as do subagents. Shared per-agent state
|
||||
(section 2) is then used by two runs at once; accepted. A subagent's report still goes to the branch that asked
|
||||
(section 2).
|
||||
|
||||
### Labels
|
||||
|
||||
|
|
|
|||
|
|
@ -54,10 +54,13 @@ export default defineFacet((host) => {
|
|||
0,
|
||||
...messages.filter((message) => message.role === "assistant").map((message) => message.entryId),
|
||||
);
|
||||
// Unread answers are the main conversation's (conversation 1 or the `/tree` branch the session continues in).
|
||||
// Unread answers are the top-level conversations' (the main one and the other `/tree` branches): a branch left
|
||||
// while running answers in the background. `branches` is empty until the worker first reports it.
|
||||
const row =
|
||||
answer > (markers.value.answers[session] ?? 0) ? await catalog.get({ id: session }, ctx) : undefined;
|
||||
if (
|
||||
answer > (markers.value.answers[session] ?? 0) &&
|
||||
conversation === (await catalog.get({ id: session }, ctx)).main
|
||||
row &&
|
||||
(conversation === row.main || row.branches.some((branch) => branch.conversation === conversation))
|
||||
)
|
||||
await markers.change((draft) => {
|
||||
draft.answers[session] = answer;
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ const cleanTitle = (title: string): string => title.replace(/\s+/g, " ").trim();
|
|||
* server's own (see `lifecycle`).
|
||||
*/
|
||||
async function fenceWorker(
|
||||
catalog: Ref<typeof Catalog>,
|
||||
children: Ref<typeof ChildStore>,
|
||||
id: string,
|
||||
doing: string,
|
||||
|
|
@ -119,18 +120,28 @@ async function fenceWorker(
|
|||
const result = await boundary.prepare(to ? { to } : null, root);
|
||||
if (result.accepted) return undefined;
|
||||
if (result.refusal !== undefined) return result.refusal;
|
||||
const row = await catalog.get({ id }, root);
|
||||
// A `/tree` branch left while running goes on in the background (docs/tree.md); conversation 1 is top-level before
|
||||
// the worker first reports `branches`.
|
||||
const branchOf = (conversation: number) => row.branches.find((branch) => branch.conversation === conversation);
|
||||
const topLevel = (conversation: number) => conversation === 1 || branchOf(conversation) !== undefined;
|
||||
const branches = result.busy
|
||||
.filter((conversation) => topLevel(conversation) && conversation !== row.main)
|
||||
.map((conversation) => `"${branchOf(conversation)?.label ?? row.title}"`);
|
||||
const agents = result.busy
|
||||
.filter((id) => id !== 1)
|
||||
.filter((conversation) => !topLevel(conversation))
|
||||
.map(
|
||||
(conversation) =>
|
||||
children.children.value[id]?.find((child) => child.conversationId === conversation)?.name ??
|
||||
`#${conversation}`,
|
||||
);
|
||||
return result.busy.includes(1)
|
||||
return result.busy.includes(row.main)
|
||||
? `Stop the current run before ${doing}`
|
||||
: agents.length
|
||||
? `Subagent ${agents.join(", ")} is still running: stop it before ${doing}`
|
||||
: `Background work is still running (for example a message waiting for a subagent's answer): wait for it or stop the subagent before ${doing}`;
|
||||
: branches.length
|
||||
? `Branch ${branches.join(", ")} is still running: switch to it and stop it before ${doing}`
|
||||
: agents.length
|
||||
? `Subagent ${agents.join(", ")} is still running: stop it before ${doing}`
|
||||
: `Background work is still running (for example a message waiting for a subagent's answer): wait for it or stop the subagent before ${doing}`;
|
||||
}
|
||||
|
||||
export default defineFacet((host) => {
|
||||
|
|
@ -239,7 +250,7 @@ export default defineFacet((host) => {
|
|||
const boundary = () => root.get(WorkerBoundary, { session: id });
|
||||
return server().workers.relocate(id, {
|
||||
prepare: async () => {
|
||||
const refusal = await fenceWorker(children, id, "changing directory", root, location);
|
||||
const refusal = await fenceWorker(catalog, children, id, "changing directory", root, location);
|
||||
refused = refusal ?? null;
|
||||
return refusal;
|
||||
},
|
||||
|
|
@ -320,7 +331,7 @@ export default defineFacet((host) => {
|
|||
const server = self.get(Server, {});
|
||||
const moves = self.get(SessionMoves, {});
|
||||
const fence = (id: string, doing: string, root: Ctx, to?: Location) =>
|
||||
fenceWorker(children, id, doing, root, to);
|
||||
fenceWorker(catalog, children, id, doing, root, to);
|
||||
/**
|
||||
* Runs a lifecycle operation's steps in a root of the server's own, not the caller's: a caller giving up (an
|
||||
* aborted call, a closed tab) must not cut a fenced worker off between its steps.
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ export type Subagent = {
|
|||
* environment it was spawned on before the Harness is live, so no one else reads it.
|
||||
*/
|
||||
unplacedCwd?: string;
|
||||
/**
|
||||
* The top-level conversation that last spawned it or sent it a message (the main conversation or a `/tree` branch):
|
||||
* where `team send to: "parent"` goes. Absent (spawned before it was recorded): the current main conversation.
|
||||
*/
|
||||
parent?: ConversationId;
|
||||
/** Answers already reported: several messages can end in one answer, reported once. */
|
||||
reported: EntryId[];
|
||||
/** Hidden from the sidebar's list and `status`; still usable, and `send` restores it. */
|
||||
|
|
|
|||
|
|
@ -129,11 +129,16 @@ async function latestAnswer(api: ToolExecutionApi, conversationId: ConversationI
|
|||
}, context);
|
||||
}
|
||||
|
||||
// Delivers one message, waits for the answer, then reports it to the main agent
|
||||
// Delivers one message, waits for the answer, then reports it to the conversation that sent the message
|
||||
// as a follow-up input. Request IDs make both submissions exactly-once across restarts.
|
||||
type ReporterInput = {
|
||||
name: string;
|
||||
conversationId: ConversationId;
|
||||
/**
|
||||
* The conversation that sent the message, which the report goes to: a `/tree` branch left while the subagent works
|
||||
* still gets its answer. Absent (checkpointed before it was recorded): the current main conversation.
|
||||
*/
|
||||
parent?: ConversationId;
|
||||
message: string;
|
||||
followUp: boolean;
|
||||
/** The owner started the main agent's turn: the subagent's turn inherits it (`SUBAGENT_TASK_ENTRY`). */
|
||||
|
|
@ -181,10 +186,9 @@ const Reporter = defineTask<ReporterInput, ReporterState, null>({
|
|||
report: async (reporter, runtime, context) => {
|
||||
const report = reporter.state.checkpoint.report;
|
||||
if (report !== undefined) {
|
||||
const { name, conversationId } = reporter.input;
|
||||
// To the current main conversation: a `/tree` switch while the subagent worked moves where its answer goes.
|
||||
const main = await runtime.conversation(await mainConversation(runtime, context), context);
|
||||
await main?.submit(
|
||||
const { name, conversationId, parent } = reporter.input;
|
||||
const target = await runtime.conversation(parent ?? (await mainConversation(runtime, context)), context);
|
||||
await target?.submit(
|
||||
{
|
||||
type: "input",
|
||||
...(typeof report === "string"
|
||||
|
|
@ -555,6 +559,7 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator
|
|||
if (!target) return `No subagent named ${name}.`;
|
||||
// Working with an archived subagent again brings it back.
|
||||
delete target.archived;
|
||||
target.parent = api.conversationId;
|
||||
// Subagents spawned before the team or history tools existed get them now.
|
||||
if (!target.team || !target.history) {
|
||||
const all = installed();
|
||||
|
|
@ -566,6 +571,7 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator
|
|||
const input = {
|
||||
name,
|
||||
conversationId: target.conversationId,
|
||||
parent: api.conversationId,
|
||||
message: message ?? "",
|
||||
followUp: action === "send" && followUp === true,
|
||||
...(origin === "owner" ? { origin } : {}),
|
||||
|
|
@ -660,7 +666,11 @@ const teamTool: ToolRegistration = defineTool({
|
|||
if (!args.message?.trim()) return reply("send needs a message.", true);
|
||||
const sibling = args.to === "parent" ? undefined : siblings.find(([name]) => name === args.to)?.[1];
|
||||
if (args.to !== "parent" && !sibling) return reply(`No other subagent named ${args.to}.`, true);
|
||||
const target = await api.conversation(sibling?.conversationId ?? (await mainConversation(api, context)), context);
|
||||
const parent = registry?.agents[self]?.parent;
|
||||
const target = await api.conversation(
|
||||
sibling?.conversationId ?? parent ?? (await mainConversation(api, context)),
|
||||
context,
|
||||
);
|
||||
if (!target) return reply(`${args.to} has no conversation.`, true);
|
||||
await target.submit(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -57,14 +57,14 @@ const SessionTreeV1 = remote("p2.tree", {
|
|||
/**
|
||||
* Continues the session at `entry` (pi's `/tree`): after it for an answer, before it for a user message, whose
|
||||
* text comes back for the editor (null otherwise). Switches to the branch ending there, else forks a new one
|
||||
* (a new empty conversation before the first message), labelled with the chosen message's start. Refused
|
||||
* while the main conversation works. Returns the new main conversation.
|
||||
* (a new empty conversation before the first message), labelled with the chosen message's start. A run of
|
||||
* the conversation left goes on in the background. Returns the new main conversation.
|
||||
*/
|
||||
navigate: method(
|
||||
obj({ entry: int({ minimum: 1 }) }),
|
||||
obj({ conversation: int({ minimum: 1 }), text: union([str(), nul()]) }),
|
||||
),
|
||||
/** Continues the session at the end of the branch `conversation` (a sidebar row); refused while busy. */
|
||||
/** Continues the session at the end of the branch `conversation` (a sidebar row); a run goes on. */
|
||||
select: method(obj({ conversation: int({ minimum: 1 }) })),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ const NOT_CURRENT = "This is an earlier branch of the session: switch to it with
|
|||
export default defineFacet((host) => {
|
||||
contribute(host, "p2.tree-branches", (self) => {
|
||||
const harness = harnessOf(self);
|
||||
// Only the current main conversation runs: an earlier branch shows its history but takes no input.
|
||||
// Only the current main conversation takes input: an earlier branch shows its history, and a run it still has goes
|
||||
// on in the background (Stop stays allowed).
|
||||
self.contribute(ReadOnlyConversations, {
|
||||
tree: async (conversation, ctx) => {
|
||||
const durable = harness().durable;
|
||||
|
|
@ -293,7 +294,8 @@ function assistantText(entry: EntryRecord): string {
|
|||
* text comes back). A top-level conversation already ending there is reused, its label unchanged; else a fork of the
|
||||
* conversation storing the point, or a new empty conversation before the first message, labelled with the chosen
|
||||
* message (what the owner picked: the answer, or the user message typed again). The new conversation, its label, the
|
||||
* carried documents and the main pointer are one commit.
|
||||
* carried documents and the main pointer are one commit. A run of the conversation left keeps going in the background,
|
||||
* read-only.
|
||||
*/
|
||||
async function navigate(
|
||||
durable: Harness,
|
||||
|
|
@ -301,7 +303,6 @@ async function navigate(
|
|||
context: Context,
|
||||
): Promise<{ conversation: number; text: string | null }> {
|
||||
const main = await mainConversation(durable, context);
|
||||
await idleOrRefuse(durable, main, context);
|
||||
const chosen = await durable.commit((tx) => tx.entry(entry), context);
|
||||
if (!chosen || !(await isTopLevel(durable, chosen.conversationId, context))) throw new Error("Unknown message");
|
||||
const role = roleOf(chosen);
|
||||
|
|
@ -339,22 +340,10 @@ async function navigate(
|
|||
|
||||
const OWNERLESS = { ownership: { kind: "ownerless" } } as const;
|
||||
|
||||
/** Refuses a switch while the main conversation works: a run, queued input, or a task of its own. */
|
||||
async function idleOrRefuse(durable: Harness, main: ConversationId, context: Context): Promise<void> {
|
||||
const inspection = await durable.inspect(context);
|
||||
if (
|
||||
inspection.tasks.some((task) => task.record.conversationId === main && !task.record.background) ||
|
||||
inspection.submissions.some((submission) => submission.conversationId === main)
|
||||
)
|
||||
throw new Error("Stop the current run before switching branches");
|
||||
}
|
||||
|
||||
/** Switches the main conversation to the top-level conversation `id` (its latest message), refused while busy. */
|
||||
/** Switches the main conversation to the top-level conversation `id` (its latest message); a run there goes on. */
|
||||
async function select(durable: Harness, id: ConversationId, context: Context): Promise<void> {
|
||||
if (!(await isTopLevel(durable, id, context))) throw new Error("Unknown branch");
|
||||
const main = await mainConversation(durable, context);
|
||||
if (id === main) return;
|
||||
await idleOrRefuse(durable, main, context);
|
||||
if (id === (await mainConversation(durable, context))) return;
|
||||
await durable.commit(async (tx) => {
|
||||
(await tx.doc(MainDoc)).conversation = id;
|
||||
}, context);
|
||||
|
|
|
|||
|
|
@ -73,7 +73,8 @@ function ThemeButton() {
|
|||
/**
|
||||
* A session's `/tree` branches (docs/tree.md), newest first in a stable order: switching only moves the "current" mark.
|
||||
* Shown from two branches on, behind the session row's expand control (`hidden` while collapsed). Choosing another branch switches the session to it (`onBranch`, the owner's); the pencil beside a
|
||||
* row renames it (`onRenameBranch`). A branch without a label (conversation 1's default) shows the session title.
|
||||
* row renames it (`onRenameBranch`). A branch without a label (conversation 1's default) shows the session title. A
|
||||
* branch left while running goes on in the background: its row shows the working dot.
|
||||
*/
|
||||
function BranchRows({
|
||||
session,
|
||||
|
|
@ -111,6 +112,9 @@ function BranchRows({
|
|||
<BranchIcon size={13} />
|
||||
<span className="session-title">{label}</span>
|
||||
{current && <span className="session-meta">current</span>}
|
||||
{session.worker.busy?.includes(branch.conversation) && (
|
||||
<span className="live-dot" title="Working" />
|
||||
)}
|
||||
</button>
|
||||
{onRenameBranch && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// `/tree` (docs/tree.md) on the real server and session worker with the fake provider: the tree of the main
|
||||
// conversation and its branches, switching (forking, reusing a branch, before the first message), the session row's
|
||||
// `main`, read-only earlier branches, main-only tools in a branch, the refusals, branch labels (stored at the fork,
|
||||
// `main`, read-only earlier branches, main-only tools in a branch, switching while busy, branch labels (stored at the fork,
|
||||
// renamed, conversation 1's null for the session title) and `p2.main` version 1's migration.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
|
|
@ -167,7 +167,7 @@ describe("/tree (fake provider)", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("registers a branch's subagents on conversation 1 and reports to the current main", async () => {
|
||||
it("registers a branch's subagents on conversation 1 and reports to the branch", async () => {
|
||||
const tree = await (await treeRef()).tree(BACKGROUND_CONTEXT);
|
||||
const chat = await chatOf(tree.main);
|
||||
await chat.send({ text: "subagent", requestId: "subagent" }, BACKGROUND_CONTEXT);
|
||||
|
|
@ -182,6 +182,39 @@ describe("/tree (fake provider)", () => {
|
|||
feed.release();
|
||||
});
|
||||
|
||||
it("reports to the branch that asked, also after a switch; team send to parent goes there too", async () => {
|
||||
const ref = await treeRef();
|
||||
const asking = (await ref.tree(BACKGROUND_CONTEXT)).main;
|
||||
const chat = await chatOf(asking);
|
||||
const spawn = { action: "spawn", name: "slowpoke", message: "slow-tool", tools: ["bash"] };
|
||||
await chat.send({ text: `subagent ${JSON.stringify(spawn)}`, requestId: "slowpoke" }, BACKGROUND_CONTEXT);
|
||||
await until(chat.view, (next) => text(next).includes("Started slowpoke."));
|
||||
// The subagent's bash call takes 4 seconds: the session switches meanwhile.
|
||||
await ref.select({ conversation: 1 }, BACKGROUND_CONTEXT);
|
||||
await until(chat.view, (next) => text(next).includes("[subagent slowpoke answered"));
|
||||
const feed = await holdIn(owner, Subagents, {}, "reader");
|
||||
const children = await until(
|
||||
feed.children,
|
||||
(value) => value?.[session.id]?.some((child) => child.name === "slowpoke") ?? false,
|
||||
);
|
||||
feed.release();
|
||||
const slowpoke = children?.[session.id]?.find((child) => child.name === "slowpoke")?.conversationId;
|
||||
assert(slowpoke !== undefined);
|
||||
const direct = await chatOf(slowpoke);
|
||||
const team = { action: "send", to: "parent", message: "for the asker", intent: "note" };
|
||||
await direct.send({ text: `team ${JSON.stringify(team)}`, requestId: "team-parent" }, BACKGROUND_CONTEXT);
|
||||
await until(chat.view, (next) => text(next).includes("[subagent slowpoke notes, no reply needed] for the asker"));
|
||||
direct.release();
|
||||
chat.release();
|
||||
await idle();
|
||||
const main = await chatOf(1);
|
||||
const view = await until(main.view, (next) => next !== null && next !== undefined);
|
||||
assert.doesNotMatch(text(view), /subagent slowpoke/);
|
||||
main.release();
|
||||
await ref.select({ conversation: asking }, BACKGROUND_CONTEXT);
|
||||
ref.release();
|
||||
});
|
||||
|
||||
it("lists the branches in the session row and switches with select", async () => {
|
||||
const sessions = await sessionsRef();
|
||||
const rows = await until(
|
||||
|
|
@ -288,19 +321,29 @@ describe("/tree (fake provider)", () => {
|
|||
assert.equal((await ref.tree(BACKGROUND_CONTEXT)).branches[1]?.label, "Second try");
|
||||
});
|
||||
|
||||
it("refuses while the main conversation works", async () => {
|
||||
it("switches while the main conversation works; the branch left runs on, read-only, and stops", async () => {
|
||||
const ref = await treeRef();
|
||||
const tree = await ref.tree(BACKGROUND_CONTEXT);
|
||||
const chat = await chatOf(tree.main);
|
||||
const left = tree.main;
|
||||
const chat = await chatOf(left);
|
||||
await chat.send({ text: "slow answer", requestId: "slow" }, BACKGROUND_CONTEXT);
|
||||
await until(chat.view, (next) => Boolean((next?.docs["pi.live"] as { run?: unknown } | undefined)?.run));
|
||||
await ref.select({ conversation: 1 }, BACKGROUND_CONTEXT);
|
||||
const sessions = await sessionsRef();
|
||||
await until(sessions.sessions, (list) => list.find((each) => each.id === session.id)?.main === 1);
|
||||
const status = await holdIn(owner, SessionStatus, {}, "reader");
|
||||
await until(status.statuses, (value) => value?.[session.id]?.busy.includes(left) ?? false);
|
||||
status.release();
|
||||
await assert.rejects(
|
||||
ref.navigate({ entry: nodeWith(tree, "user", "first").entry }, BACKGROUND_CONTEXT),
|
||||
/Stop the current run/,
|
||||
chat.send({ text: "no", requestId: "no-left" }, BACKGROUND_CONTEXT),
|
||||
/earlier branch of the session/,
|
||||
);
|
||||
await chat.stop(BACKGROUND_CONTEXT);
|
||||
await until(chat.view, (next) => !(next?.docs["pi.live"] as { run?: unknown } | undefined)?.run);
|
||||
chat.release();
|
||||
await idle();
|
||||
await ref.select({ conversation: left }, BACKGROUND_CONTEXT);
|
||||
await until(sessions.sessions, (list) => list.find((each) => each.id === session.id)?.main === left);
|
||||
await assert.rejects(ref.navigate({ entry: 999_999 }, BACKGROUND_CONTEXT), /Unknown message/);
|
||||
ref.release();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue