Merge remote-tracking branch 'origin/dev' into codex/fix-pr241-ci
This commit is contained in:
commit
74d0e31474
20 files changed
+434
-44
No files matched your search
@@ -0,0 +1,46 @@
|
||||
name: Something is broken
|
||||
description: Sisyphus did something it should not, or did not do something it should.
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What you did, what you expected, and what happened instead. Paste any error exactly.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: How to make it happen again
|
||||
placeholder: |
|
||||
1. sisyphusd run --coordinator ...
|
||||
2. sisyphusd submit primes ...
|
||||
- type: dropdown
|
||||
id: part
|
||||
attributes:
|
||||
label: Which part
|
||||
multiple: true
|
||||
options:
|
||||
- The node (sisyphusd)
|
||||
- The desktop app
|
||||
- The planner or models
|
||||
- The MCP server or skill
|
||||
- Storage or IPFS
|
||||
- Installing or building
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version or commit
|
||||
description: "`sisyphusd version`, or the commit you built from."
|
||||
- type: input
|
||||
id: system
|
||||
attributes:
|
||||
label: System
|
||||
placeholder: Windows 11, RTX 4050, Ollama 0.21
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: The node's output around the problem. Remove anything private first.
|
||||
render: text
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Ask a question
|
||||
url: https://github.com/sisyphus-network/Sisyphus/discussions/categories/q-a
|
||||
about: How to run a node, lend a machine, or use the desktop app. Questions get answered in Discussions.
|
||||
- name: Share an idea
|
||||
url: https://github.com/sisyphus-network/Sisyphus/discussions/categories/ideas
|
||||
about: Something Sisyphus could do. Ideas start in Discussions and become issues once there is a plan.
|
||||
- name: Report a vulnerability
|
||||
url: https://github.com/sisyphus-network/Sisyphus/security/policy
|
||||
about: Please report security problems privately, not in a public issue.
|
||||
@@ -0,0 +1,23 @@
|
||||
name: A planned piece of work
|
||||
description: Something to build, once it has been talked through. Open ideas belong in Discussions first.
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why
|
||||
description: What someone cannot do today, or what goes wrong.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What
|
||||
description: What would be built, and how we will know it works.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: discussion
|
||||
attributes:
|
||||
label: Discussion
|
||||
description: A link to where this was talked through, if anywhere.
|
||||
@@ -0,0 +1,11 @@
|
||||
## What and why
|
||||
|
||||
<!-- What this changes, and the issue it closes: "Closes #N". -->
|
||||
|
||||
## How it was checked
|
||||
|
||||
<!-- The tests added, and anything run by hand: on which machines, with what result. -->
|
||||
|
||||
- [ ] `make cover` passes (every statement tested) for daemon changes
|
||||
- [ ] `npm run typecheck`, `npm test` and `npm run build` pass for desktop changes
|
||||
- [ ] The README, `apps/sisyphusd/README.md` and `docs/` say what changed
|
||||
@@ -14,6 +14,8 @@ jobs:
|
||||
# from this repository and pushes to dev use the project's Linux runner.
|
||||
runs-on: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'ubuntu-latest' || 'sisyphus-ci' }}
|
||||
env:
|
||||
# All three self-hosted runners share the same host loopback and ephemeral-port space.
|
||||
SISYPHUS_CI_LOCAL_NETWORK_LOCK: /tmp/sisyphus-ci-local-network.lock
|
||||
# Fail, rather than skip, any test that finds no ipfs program.
|
||||
SISYPHUS_REQUIRE_KUBO: "1"
|
||||
# Likewise any that finds it cannot use Docker.
|
||||
@@ -67,14 +69,23 @@ jobs:
|
||||
- run: go vet ./...
|
||||
- name: Check for mistakes vet does not look for
|
||||
run: go run honnef.co/go/tools/cmd/staticcheck@2026.2.1 ./...
|
||||
- run: go test -race ./...
|
||||
- name: Test against real local services
|
||||
# Each Go package test process has its own test-only port allocator.
|
||||
# Keep packages sequential as well as keeping this step exclusive with
|
||||
# other localhost-heavy workflows on the shared host.
|
||||
run: flock "$SISYPHUS_CI_LOCAL_NETWORK_LOCK" go test -race -p 1 ./...
|
||||
- name: Check every statement is tested
|
||||
run: |
|
||||
if command -v make >/dev/null 2>&1; then
|
||||
make cover
|
||||
else
|
||||
nix develop --command make cover
|
||||
fi
|
||||
flock "$SISYPHUS_CI_LOCAL_NETWORK_LOCK" bash -euo pipefail -c '
|
||||
# Coverage runs the same integration tests as above. Keep their
|
||||
# package processes sequential so test-only ports cannot overlap.
|
||||
export GOFLAGS="-p=1"
|
||||
if command -v make >/dev/null 2>&1; then
|
||||
make cover
|
||||
else
|
||||
nix develop --command make cover
|
||||
fi
|
||||
'
|
||||
- run: go build ./apps/sisyphusd
|
||||
|
||||
# A pool across machines, as near as one runner gets: each node in a
|
||||
@@ -99,6 +110,9 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/sisyphus
|
||||
env:
|
||||
# Electron's test node binds loopback ports just like the Go integration suite.
|
||||
SISYPHUS_CI_LOCAL_NETWORK_LOCK: /tmp/sisyphus-ci-local-network.lock
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
@@ -119,9 +133,10 @@ jobs:
|
||||
- name: Build matching daemon for desktop lifecycle acceptance
|
||||
working-directory: .
|
||||
run: go build -o bin/sisyphusd ./apps/sisyphusd
|
||||
- run: npm run test:daemon
|
||||
- name: Test desktop daemon lifecycle
|
||||
run: flock "$SISYPHUS_CI_LOCAL_NETWORK_LOCK" npm run test:daemon
|
||||
- run: npm run build
|
||||
# The real app, built above, driven in a window nobody sees against
|
||||
# the daemon built above, on a node and a profile of the tests' own.
|
||||
- name: End-to-end tests of the desktop app
|
||||
run: npm run test:e2e
|
||||
run: flock "$SISYPHUS_CI_LOCAL_NETWORK_LOCK" npm run test:e2e
|
||||
@@ -0,0 +1,9 @@
|
||||
# Code of conduct
|
||||
|
||||
Sisyphus adopts the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) as its code of conduct. It applies in the repository, its issues, pull requests and discussions, and wherever someone speaks for the project.
|
||||
|
||||
In short: be considerate, assume good faith, criticise work and not people, and leave room for those who are new. Harassment, personal attacks and unwelcome sexual attention are not accepted in any form.
|
||||
|
||||
## Reporting
|
||||
|
||||
If someone's conduct concerns you, tell a maintainer privately: [@rvnminers-A-and-N](https://github.com/rvnminers-A-and-N) or [@excho0](https://github.com/excho0). Reports are kept confidential. The maintainers decide what happens, following the Covenant's [enforcement guidelines](https://www.contributor-covenant.org/version/2/1/code_of_conduct/#enforcement-guidelines), from a private word up to a ban from the project.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Contributing to Sisyphus
|
||||
|
||||
Sisyphus is an early prototype built in the open. Issues, questions and pull requests are welcome.
|
||||
Sisyphus is an early prototype built in the open. Issues, questions and pull requests are welcome. Questions and early ideas go to [Discussions](https://github.com/sisyphus-network/Sisyphus/discussions); bugs and planned work go to issues. Everyone taking part follows the [code of conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Before you start
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
<h1 align="center">Sisyphus</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/sisyphus-network/Sisyphus/actions/workflows/ci.yml"><img src="https://github.com/sisyphus-network/Sisyphus/actions/workflows/ci.yml/badge.svg?branch=dev" alt="CI" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue" alt="Apache 2.0 license" /></a>
|
||||
<a href="https://github.com/sisyphus-network/Sisyphus/discussions"><img src="https://img.shields.io/github/discussions/sisyphus-network/Sisyphus" alt="Discussions" /></a>
|
||||
<a href="https://github.com/sisyphus-network/Sisyphus/issues/23"><img src="https://img.shields.io/badge/roadmap-%2323-8a2be2" alt="Roadmap" /></a>
|
||||
</p>
|
||||
|
||||
**Sisyphus is a decentralized network for useful compute.** Independent participants contribute CPU and GPU capacity, and users and AI agents submit computational work to the network. Its planned economic layer uses SISY to coordinate incentives, supply, and demand.
|
||||
|
||||
AI makes the network easier to use: describe a problem, and Sisyphus can plan the work, find suitable capacity, coordinate execution, inspect the results, and decide what to compute next.
|
||||
@@ -244,7 +251,7 @@ The development shell includes Go, gopls, Delve, lint tools, Protocol Buffer gen
|
||||
|
||||
## Project status
|
||||
|
||||
Sisyphus is an early prototype. The network loop works without AI: a coordinator splits a job, workers on other nodes run the tasks, and the results are combined. Everything so far has run on one machine as separate processes; it has not yet been tried across physically separate machines.
|
||||
Sisyphus is an early prototype. The network loop works without AI: a coordinator splits a job, workers on other nodes run the tasks, and the results are combined. It has run across physically separate machines on a home network, a Linux server and a Windows laptop with a graphics card, splitting a job between them and serving a language model from the card ([#18](https://github.com/sisyphus-network/Sisyphus/issues/18), [#87](https://github.com/sisyphus-network/Sisyphus/issues/87)).
|
||||
|
||||
**Working today**, in `sisyphusd`, written in Go:
|
||||
|
||||
@@ -272,6 +279,8 @@ Sisyphus is an early prototype. The network loop works without AI: a coordinator
|
||||
- Jobs made of steps, each a job of its own that can use the results of those before it.
|
||||
- Opt-in verification by replication: a job can ask that each of its tasks be run by several different workers and take a result only when they agree. A worker that returns something else is outvoted and named. It is for deterministic work, and does not stop workers that agree to lie. With spot checks only a share of the tasks, picked at random, is verified, and a worker that is caught has its other tasks in the job verified after all. A coordinator can count workers at one address as one, and can check a share of any job's tasks by running them again itself, which needs no second worker.
|
||||
- The node's local API over Connect and gRPC-Web as well as gRPC, so that a page in a browser can use it.
|
||||
- The desktop app's interface served by a node to a browser, which is paired with the node once by a one-time code and can be revoked device by device.
|
||||
- Several named model providers kept by a node, each with its own key, which never leaves the node.
|
||||
- A desktop app that shows a node and its pool, plans with it and admits or removes workers, packaged with the logo as a Windows installer, a macOS disk image and a Linux AppImage.
|
||||
- A first real workload, `transcode`: a video is cut into stretches, encoded by ffmpeg on different workers, and joined.
|
||||
- Container images as jobs: a worker whose owner allows it runs any image, one copy for each task, with its output stored and its log followed live.
|
||||
@@ -284,6 +293,12 @@ Try it with `make demo`, or see [Trying it](#trying-it) below. [`apps/sisyphusd/
|
||||
|
||||
Two things in this section go beyond what the sections above describe and are still open for discussion: the choice of Go ([#1](https://github.com/sisyphus-network/Sisyphus/issues/1)), and building storage on IPFS formats while V0 lists IPFS as out of scope ([#25](https://github.com/sisyphus-network/Sisyphus/issues/25)).
|
||||
|
||||
## Community
|
||||
|
||||
- **[Discussions](https://github.com/sisyphus-network/Sisyphus/discussions)**: questions, ideas, and what you have run on a pool.
|
||||
- **[Issues](https://github.com/sisyphus-network/Sisyphus/issues)**: bugs and planned work. The [board](https://github.com/orgs/sisyphus-network/projects/1) shows what is in progress.
|
||||
- **[Contributing](CONTRIBUTING.md)**, the [code of conduct](CODE_OF_CONDUCT.md), and [reporting a vulnerability](SECURITY.md).
|
||||
|
||||
## License
|
||||
|
||||
Sisyphus is licensed under the [Apache License, Version 2.0](LICENSE). Contributions are accepted under the same license.
|
||||
@@ -355,8 +355,13 @@ test('the topology canvas stays fitted through live window resizes', async (t) =
|
||||
|
||||
let observedBufferResize = false
|
||||
for (const [nextWidth, nextHeight] of [[1040, 720], [1280, 900], [980, 760], [1180, 800]]) {
|
||||
await app.evaluate(({ BrowserWindow }, [w, h]) => BrowserWindow.getAllWindows()[0].setSize(w, h), [nextWidth, nextHeight])
|
||||
await page.waitForFunction(([w, h]) => Math.abs(window.innerWidth - w) < 30 && Math.abs(window.innerHeight - h) < 80, [nextWidth, nextHeight])
|
||||
const previous = await canvas.evaluate((element) => ({ width: element.width, height: element.height }))
|
||||
await app.evaluate(({ BrowserWindow }, [w, h]) => BrowserWindow.getAllWindows()[0].setContentSize(w, h), [nextWidth, nextHeight])
|
||||
await page.waitForFunction(([w, h]) => Math.abs(window.innerWidth - w) < 5 && Math.abs(window.innerHeight - h) < 5, [nextWidth, nextHeight])
|
||||
await page.waitForFunction(([width, height]) => {
|
||||
const canvasEl = document.querySelector('.network-globe-stage canvas')
|
||||
return canvasEl && (canvasEl.width !== width || canvasEl.height !== height)
|
||||
}, [previous.width, previous.height])
|
||||
await page.waitForFunction(() => {
|
||||
const stageEl = document.querySelector('.network-globe-stage')
|
||||
const canvasEl = stageEl?.querySelector('canvas')
|
||||
@@ -405,14 +410,13 @@ test('the workspace switches layouts live across tablet and desktop widths', asy
|
||||
const touch = await page.context().newCDPSession(page)
|
||||
await touch.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 })
|
||||
await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: Math.round(viewportWidth * 0.72), y: 360 }] })
|
||||
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: Math.round(viewportWidth * 0.72), y: 360 }] })
|
||||
await touch.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: Math.round(viewportWidth * 0.55), y: 360 }] })
|
||||
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
await page.waitForTimeout(300)
|
||||
assert.equal(await page.locator('.workspace-mobile-pane[data-active="true"]').getByRole('img', { name: /This node/ }).count(), 0, 'a swipe should not switch views; mobile navigation is dock-driven')
|
||||
await dockButtons.nth(1).click()
|
||||
await page.locator('.workspace-mobile-pane[data-active="true"]').getByRole('img', { name: /This node/ }).waitFor()
|
||||
await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: Math.round(viewportWidth * 0.45), y: 360 }] })
|
||||
await touch.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: Math.round(viewportWidth * 0.65), y: 360 }] })
|
||||
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
await dockButtons.nth(0).click()
|
||||
await page.locator('.workspace-mobile-pane[data-active="true"] textarea').waitFor()
|
||||
|
||||
const composer = page.locator('.workspace-mobile-pane[data-active="true"] textarea')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
export type LiquidDockItem = {
|
||||
@@ -27,12 +27,46 @@ export function LiquidGlassDock({
|
||||
const activeIndex = items.findIndex((item) => item.active);
|
||||
const visualIndex = activeIndex >= 0 ? activeIndex : 0;
|
||||
const [previousIndex, setPreviousIndex] = useState(visualIndex);
|
||||
const dockRef = useRef<HTMLFieldSetElement>(null);
|
||||
const activeRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const dock = dockRef.current;
|
||||
const active = activeRef.current;
|
||||
if (!dock || !active) return;
|
||||
|
||||
let frame = 0;
|
||||
const measure = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const option = dock.querySelectorAll<HTMLElement>(".liquid-dock__option")[visualIndex];
|
||||
if (!option) return;
|
||||
const dockBounds = dock.getBoundingClientRect();
|
||||
const optionBounds = option.getBoundingClientRect();
|
||||
// Keep the selected glass pill visually substantial as the dock gains
|
||||
// items, while centering it on the actual option and staying in bounds.
|
||||
const width = optionBounds.width + (items.length > 4 ? 8 : 0);
|
||||
const centeredRight = dockBounds.right - optionBounds.right - (width - optionBounds.width) / 2;
|
||||
active.style.setProperty("--liquid-width", `${width}px`);
|
||||
active.style.setProperty("--liquid-offset", `${centeredRight}px`);
|
||||
});
|
||||
};
|
||||
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(dock);
|
||||
dock.querySelectorAll(".liquid-dock__option").forEach((option) => observer.observe(option));
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [items.length, visualIndex]);
|
||||
|
||||
return (
|
||||
<div className={`liquid-dock-wrap ${className ?? ""}`} dir="rtl">
|
||||
<fieldset className="liquid-dock" aria-label={label} data-active={activeIndex >= 0} data-previous={previousIndex}>
|
||||
<fieldset ref={dockRef} className="liquid-dock" aria-label={label} data-active={activeIndex >= 0} data-previous={previousIndex}>
|
||||
<legend className="sr-only">{label}</legend>
|
||||
<span aria-hidden="true" className="liquid-dock__active" style={{ "--liquid-index": visualIndex } as CSSProperties} />
|
||||
<span ref={activeRef} aria-hidden="true" className="liquid-dock__active" style={{ "--liquid-index": visualIndex } as CSSProperties} />
|
||||
{items.map((item) => (
|
||||
<label key={item.id} className="liquid-dock__option" data-active={item.active ? "true" : "false"}>
|
||||
<input className="liquid-dock__input" type="radio" name={`sisyphus-navigation-${label}`} checked={item.active} readOnly aria-label={item.label} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type TouchEvent } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Eye, MessageSquare } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
@@ -288,7 +288,6 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
|
||||
const topologyDragRef = useRef<{ pointerId: number; startX: number; startY: number; startWidth: number; startRatio: number; startOpen: boolean; dragging: boolean } | null>(null)
|
||||
const topologyLastTapRef = useRef<{ at: number; x: number; y: number } | null>(null)
|
||||
const [globeReady, setGlobeReady] = useState(false)
|
||||
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
|
||||
useEffect(() => {
|
||||
if (compact || historyResizing || topologyResizing) return
|
||||
// What was chosen for the topology is kept as it was while it cannot be
|
||||
@@ -499,14 +498,7 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
|
||||
<div className="max-w-[62%] rounded-lg border border-[var(--app-line)] bg-[var(--app-card)]/85 px-3 py-2 text-end text-muted-foreground shadow-sm backdrop-blur"><span className="me-1.5 inline-block size-1.5 rounded-full bg-[var(--app-accent)] align-middle" />{messages.thisNode}{countryFlag(countryCode) ? ` · ${countryFlag(countryCode)}` : ''}{countryPosition ? <span className="ms-1.5">{countryCode}</span> : ''}</div>
|
||||
</div>
|
||||
</section>
|
||||
const handleTouchEnd = (event: TouchEvent<HTMLDivElement>) => {
|
||||
if (touchStart === null) return
|
||||
const deltaX = event.changedTouches[0].clientX - touchStart.x
|
||||
const deltaY = event.changedTouches[0].clientY - touchStart.y
|
||||
if (topologyAvailable && Math.abs(deltaX) > 55 && Math.abs(deltaX) > Math.abs(deltaY) * 1.25) setMobileView(deltaX < 0 ? 'network' : 'chat')
|
||||
setTouchStart(null)
|
||||
}
|
||||
return <div className={`workspace-shell ${compact ? 'workspace-shell--compact' : ''}`} onTouchStart={(event) => setTouchStart({ x: event.touches[0].clientX, y: event.touches[0].clientY })} onTouchEnd={handleTouchEnd}>
|
||||
return <div className={`workspace-shell ${compact ? 'workspace-shell--compact' : ''}`}>
|
||||
{compact ? <>
|
||||
<div className="workspace-mobile-stage">
|
||||
<motion.div aria-hidden={mobileView !== 'chat'} className="workspace-mobile-pane" data-active={mobileView === 'chat'} animate={reduceMotion ? undefined : { opacity: mobileView === 'chat' ? 1 : 0, x: mobileView === 'chat' ? 0 : -24 }} transition={reduceMotion ? { duration: 0 } : { type: 'tween', duration: 0.24, ease: [0.22, 1, 0.36, 1] }} style={{ zIndex: mobileView === 'chat' ? 2 : 1, pointerEvents: mobileView === 'chat' ? 'auto' : 'none' }}>
|
||||
|
||||
@@ -56,8 +56,7 @@
|
||||
.workspace-mobile-dock .liquid-dock-wrap { width: min(244px, 100%); margin: 0; pointer-events: auto; }
|
||||
.workspace-mobile-dock .liquid-dock { width: 100%; height: 70px; padding: 8px 12px 10px; }
|
||||
.workspace-mobile-dock .liquid-dock__option { flex: 1 1 0; width: auto; padding-inline: 0; }
|
||||
.workspace-mobile-dock .liquid-dock__active { right: 12px; width: calc((100% - 32px) / 2); transform: translateX(calc(var(--liquid-index) * -1 * (100% + 8px))); }
|
||||
.workspace-mobile-dock .liquid-dock[data-active="false"] .liquid-dock__active { transform: translateX(calc(var(--liquid-index) * -1 * (100% + 8px))) scale(.82,.9); }
|
||||
.workspace-mobile-dock .liquid-dock__active { right: 0; }
|
||||
.workspace-chat { min-height: 0; padding-inline: 1rem; }
|
||||
.workspace-shell--compact { touch-action: pan-y; }
|
||||
html:has(.app-shell--workspace),
|
||||
@@ -163,7 +162,7 @@
|
||||
.app-shell--advanced { min-height: 100dvh; }
|
||||
.app-main { flex: 1 1 auto; min-height: 0; }
|
||||
.app-mobile-dock { display: none; }
|
||||
@media (min-width: 761px) {
|
||||
@media (min-width: 901px) {
|
||||
.app-shell--advanced { display: flex; flex-direction: row; width: 100%; height: 100dvh; min-height: 0; overflow: hidden; }
|
||||
.app-shell-body--advanced { width: 100%; min-height: 0; overflow: hidden; }
|
||||
.app-content-column--advanced { height: 100%; overflow: hidden; }
|
||||
@@ -179,7 +178,7 @@
|
||||
.settings-page__heading { flex: 0 0 auto; }
|
||||
.settings-page__scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding-bottom: 28px; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
@media (max-width: 900px) {
|
||||
.app-shell--advanced { position: relative; display: flex; flex-direction: column; height: 100dvh; overflow: hidden; }
|
||||
.app-shell-body--advanced { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.app-content-column--advanced { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
@@ -190,14 +189,67 @@
|
||||
.app-mobile-dock .liquid-dock-wrap { width: min(280px, 100%); margin: 0; pointer-events: auto; }
|
||||
.app-mobile-dock .liquid-dock { width: 100%; }
|
||||
.app-mobile-dock .liquid-dock__option { flex: 1 1 0; min-width: 0; width: auto; padding-inline: 0; }
|
||||
.app-mobile-dock .liquid-dock__active { right: 12px; width: calc((100% - 48px) / 4); transform: translateX(calc(var(--liquid-index) * -1 * (100% + 8px))); }
|
||||
.app-mobile-dock .liquid-dock[data-active="false"] .liquid-dock__active { transform: translateX(calc(var(--liquid-index) * -1 * (100% + 8px))) scale(.82,.9); }
|
||||
.app-mobile-dock .liquid-dock__active { right: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.workspace-mobile-pane { will-change: auto; backface-visibility: visible; }
|
||||
}
|
||||
|
||||
/* Advanced navigation and the paired-device table share the app's accent system. */
|
||||
.advanced-sidebar { box-sizing: border-box; display: flex; width: 248px; flex: 0 0 248px; flex-direction: column; gap: 26px; border-inline-end: 1px solid color-mix(in srgb, var(--app-accent) 14%, var(--app-line)); background: linear-gradient(165deg, color-mix(in srgb, var(--app-accent) 6%, var(--app-sidebar)), var(--app-sidebar) 34%); padding: 22px 14px 16px; }
|
||||
.app-shell--advanced .advanced-sidebar { width: 248px; flex-basis: 248px; }
|
||||
.advanced-sidebar__brand { display: flex; min-width: 0; min-height: 62px; align-items: center; justify-content: center; border: 0; background: transparent; padding: 4px; }
|
||||
.advanced-sidebar__logo { display: block; width: 64px; height: 64px; flex: 0 0 64px; object-fit: contain; filter: drop-shadow(0 0 18px color-mix(in srgb, var(--app-accent) 26%, transparent)); }
|
||||
.advanced-sidebar__nav { display: flex; min-height: 0; flex: 1; flex-direction: column; gap: 24px; }
|
||||
.advanced-sidebar__group { display: grid; gap: 5px; }
|
||||
.advanced-sidebar__label { padding: 0 10px 5px; color: var(--app-muted); font-size: 9px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.advanced-sidebar__item { position: relative; display: flex; height: 43px; min-width: 0; align-items: center; gap: 11px; overflow: hidden; border: 1px solid transparent; border-radius: 13px; padding: 0 10px; color: var(--app-muted); font-size: 12px; text-decoration: none; transition: color 180ms ease, background 180ms ease, border-color 180ms ease, transform 180ms ease; }
|
||||
.advanced-sidebar__item:hover { transform: translateX(2px); background: color-mix(in srgb, var(--app-accent) 6%, var(--app-card)); color: var(--app-ink); }
|
||||
.advanced-sidebar__item.is-active { border-color: color-mix(in srgb, var(--app-accent) 19%, var(--app-line)); background: linear-gradient(100deg, color-mix(in srgb, var(--app-accent) 13%, var(--app-card)), color-mix(in srgb, var(--app-accent) 5%, var(--app-card))); color: var(--app-ink); box-shadow: inset 0 1px 0 color-mix(in srgb, white 13%, transparent), 0 5px 15px color-mix(in srgb, var(--app-accent) 7%, transparent); }
|
||||
.advanced-sidebar__item.is-active::after { position: absolute; inset-block: 10px; inset-inline-start: 0; width: 3px; border-radius: 0 5px 5px 0; background: var(--app-accent); content: ""; box-shadow: 0 0 12px color-mix(in srgb, var(--app-accent) 55%, transparent); }
|
||||
.advanced-sidebar__item-icon { display: grid; width: 28px; height: 28px; flex: 0 0 28px; place-items: center; border-radius: 9px; background: color-mix(in srgb, var(--app-ink) 4%, transparent); transition: background 180ms ease, color 180ms ease; }
|
||||
.advanced-sidebar__item.is-active .advanced-sidebar__item-icon { background: color-mix(in srgb, var(--app-accent) 16%, transparent); color: var(--app-accent); }
|
||||
.advanced-sidebar__status { position: relative; isolation: isolate; overflow: hidden; border: 0; border-radius: 17px; background: radial-gradient(ellipse at 5% 0%, color-mix(in srgb, var(--app-accent) 18%, transparent), transparent 68%), linear-gradient(145deg, color-mix(in srgb, var(--app-card) 88%, transparent), color-mix(in srgb, var(--app-sidebar) 94%, transparent)); padding: 14px 12px 12px; box-shadow: inset 0 1px 0 color-mix(in srgb, white 10%, transparent), 0 12px 28px color-mix(in srgb, var(--app-ink) 10%, transparent); }
|
||||
.advanced-sidebar__status-glow { position: absolute; z-index: -1; inset-inline-end: -28px; top: -34px; width: 92px; height: 92px; border-radius: 50%; background: color-mix(in srgb, var(--app-accent) 13%, transparent); filter: blur(25px); pointer-events: none; }
|
||||
.advanced-sidebar__status-dot { position: relative; width: 9px; height: 9px; flex: 0 0 9px; border-radius: 50%; background: var(--app-muted); box-shadow: 0 0 0 4px color-mix(in srgb, var(--app-muted) 10%, transparent); }
|
||||
.advanced-sidebar__status-dot.is-online { background: #20c997; box-shadow: 0 0 0 4px color-mix(in srgb, #20c997 13%, transparent), 0 0 14px color-mix(in srgb, #20c997 50%, transparent); }
|
||||
.advanced-sidebar__status-dot.is-connecting { background: #f4b942; animation: sidebar-status-pulse 1.5s ease-in-out infinite; }
|
||||
.advanced-sidebar__endpoint { border-radius: 8px; background: color-mix(in srgb, var(--app-bg) 50%, transparent); padding: 7px 8px; }
|
||||
.advanced-sidebar__peer-metric { border-radius: 10px; background: color-mix(in srgb, var(--app-accent) 6%, transparent); padding: 8px 9px; }
|
||||
@keyframes sidebar-status-pulse { 50% { opacity: .5; box-shadow: 0 0 0 7px color-mix(in srgb, #f4b942 8%, transparent); } }
|
||||
.devices-layout { display: flex; flex-direction: column; }
|
||||
.devices-section { order: -1; }
|
||||
.devices-table-wrap { overflow: hidden; border: 1px solid var(--app-line); border-radius: 17px; background: color-mix(in srgb, var(--app-card) 90%, transparent); box-shadow: 0 10px 30px color-mix(in srgb, var(--app-ink) 4%, transparent); }
|
||||
.devices-table { width: 100%; border-collapse: collapse; text-align: start; }
|
||||
.devices-table th { height: 43px; background: color-mix(in srgb, var(--app-wash) 72%, transparent); padding: 0 17px; color: var(--app-muted); font-size: 9px; font-weight: 700; letter-spacing: .1em; text-align: start; text-transform: uppercase; }
|
||||
.devices-table td { border-top: 1px solid var(--app-line); padding: 12px 17px; vertical-align: middle; }
|
||||
.devices-table__actions { width: 1%; white-space: nowrap; text-align: end !important; }
|
||||
.devices-empty { border-style: dashed; background: color-mix(in srgb, var(--app-card) 72%, transparent); }
|
||||
.devices-pair-card { width: 100%; }
|
||||
@media (min-width: 901px) { .advanced-sidebar__item:hover { transform: translateX(2px); } }
|
||||
@media (max-width: 900px) {
|
||||
.advanced-sidebar, .app-shell--advanced .advanced-sidebar { width: 205px; flex-basis: 205px; padding-inline: 10px; }
|
||||
.devices-table-wrap { overflow: visible; border: 0; background: transparent; box-shadow: none; }
|
||||
.devices-table, .devices-table tbody { display: grid; width: 100%; gap: 11px; }
|
||||
.devices-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }
|
||||
.devices-table tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 9px 14px; border: 1px solid var(--app-line); border-radius: 16px; background: color-mix(in srgb, var(--app-card) 94%, transparent); padding: 13px; box-shadow: 0 8px 22px color-mix(in srgb, var(--app-ink) 4%, transparent); }
|
||||
.devices-table td { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; border: 0; padding: 0; }
|
||||
.devices-table td:first-child { grid-column: 1 / -1; }
|
||||
.devices-table td:nth-child(2) { grid-column: 1; }
|
||||
.devices-table td:nth-child(2)::before { color: var(--app-muted); content: attr(data-label); font-size: 9px; }
|
||||
.devices-table td.devices-table__actions { grid-column: 2; grid-row: 2; }
|
||||
.devices-table__actions { width: auto; }
|
||||
.devices-table td.devices-table__actions::before { content: none; }
|
||||
.devices-table td:nth-child(2) time { text-align: end; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.devices-table tr { grid-template-columns: minmax(0, 1fr); }
|
||||
.devices-table td.devices-table__actions { grid-column: 1; grid-row: auto; justify-content: flex-end; border-top: 1px solid var(--app-line); padding-top: 9px; }
|
||||
.devices-table td:nth-child(2) { gap: 5px; align-items: flex-start; flex-direction: column; }
|
||||
.devices-table td:nth-child(2) time { text-align: start; }
|
||||
}
|
||||
|
||||
.network-visual {
|
||||
isolation: isolate;
|
||||
background: transparent;
|
||||
|
||||
@@ -77,6 +77,18 @@ bin/sisyphusd pool join --addr <coordinator-ip>:7700 <invitation> # on the oth
|
||||
bin/sisyphusd nodes --addr <coordinator-ip>:7700
|
||||
```
|
||||
|
||||
**Watching the work.** Each node's own output says what it is doing for the pool, so a pool can be followed from any of its machines:
|
||||
|
||||
```text
|
||||
coordinator msg="task given" task=34b7…/0 worker=laptop attempt=1
|
||||
laptop msg="task started" job=34b7… task=34b7…/0 workload=chat attempt=1 model=llama3.1:8b
|
||||
laptop msg="task done" job=34b7… task=34b7…/0 took=8.076s output_bytes=283
|
||||
coordinator msg="task returned" task=34b7…/0 worker=laptop took=8.079s
|
||||
coordinator msg="job finished" job=34b7… state=succeeded took=8.95s by="laptop×1"
|
||||
```
|
||||
|
||||
A task that fails says why on the worker (`task failed … error=…`); one the coordinator took back says `task stopped`. A private job's tasks are logged without the model they ask for. `-v` adds the lower-level detail.
|
||||
|
||||
## Who can connect
|
||||
|
||||
Every connection between nodes is encrypted, and each end proves who it is.
|
||||
|
||||
+11
-2
@@ -209,11 +209,20 @@ func readKey(file string) (sealed.Key, error) {
|
||||
|
||||
// keyCommand makes sealing keys.
|
||||
func keyCommand(args []string) error {
|
||||
if len(args) != 2 || args[0] != "new" {
|
||||
if len(args) == 0 || args[0] != "new" {
|
||||
return errors.New(`expected "key new <file>"`)
|
||||
}
|
||||
// Parsed as flags, so that --help is asked for help and not taken for
|
||||
// the name of a file to put a key in.
|
||||
fs := flag.NewFlagSet("sisyphusd key new <file>", flag.ContinueOnError)
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 1 {
|
||||
return errors.New(`expected "key new <file>"`)
|
||||
}
|
||||
// Created, never overwritten: replacing a key loses whatever it sealed.
|
||||
file, err := os.OpenFile(args[1], os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
file, err := os.OpenFile(fs.Arg(0), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create key file: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package coordinator
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -1115,6 +1117,7 @@ func (c *Coordinator) handleResult(w *worker, result *pb.TaskResult) {
|
||||
}
|
||||
switch outcome := result.GetOutcome().(type) {
|
||||
case *pb.TaskResult_Output:
|
||||
c.log.Info("task returned", "task", a.task.ID, "worker", w.name, "took", now.Sub(a.started).Round(time.Millisecond).String())
|
||||
// What the task stored must outlive the hour a new blob is kept
|
||||
// for, since the job may run longer than that. Only what the store
|
||||
// holds is taken as stored: the worker's word for it is not enough.
|
||||
@@ -1192,11 +1195,34 @@ func (c *Coordinator) aggregate(job *jobmodel.Job) {
|
||||
job.NoteResult(touched.Written()...)
|
||||
job.Finish(result, err, now)
|
||||
c.settleLocked(job)
|
||||
c.log.Info("job finished", "job", job.ID, "state", job.State.String(), "took", now.Sub(job.CreatedAt).String())
|
||||
c.log.Info("job finished", "job", job.ID, "state", job.State.String(), "took", now.Sub(job.CreatedAt).String(), "by", workedBy(job))
|
||||
c.notifyLocked(job)
|
||||
c.scheduleLocked()
|
||||
}
|
||||
|
||||
// workedBy says which workers did a job's tasks and how many each did, as
|
||||
// "laptop×12 r620×20", most first.
|
||||
func workedBy(job *jobmodel.Job) string {
|
||||
counts := map[string]int{}
|
||||
for _, task := range job.Tasks {
|
||||
if task.NodeName != "" {
|
||||
counts[task.NodeName]++
|
||||
}
|
||||
}
|
||||
names := slices.Collect(maps.Keys(counts))
|
||||
slices.SortFunc(names, func(a, b string) int {
|
||||
if c := cmp.Compare(counts[b], counts[a]); c != 0 {
|
||||
return c
|
||||
}
|
||||
return strings.Compare(a, b)
|
||||
})
|
||||
said := make([]string, len(names))
|
||||
for i, name := range names {
|
||||
said[i] = fmt.Sprintf("%s×%d", name, counts[name])
|
||||
}
|
||||
return strings.Join(said, " ")
|
||||
}
|
||||
|
||||
// settleLocked sets how long a job's blobs are kept once it is over: its
|
||||
// inputs and results for the retention period, and what only passed between
|
||||
// its tasks no longer. It does nothing while the job is still going.
|
||||
@@ -1419,6 +1445,7 @@ func (c *Coordinator) scheduleLocked() {
|
||||
// handLocked gives a worker an attempt at a task.
|
||||
func (c *Coordinator) handLocked(w *worker, a assignment) {
|
||||
c.recordLocked(a.job, eventTaskStarted, a.task.Index, w.name, fmt.Sprintf("attempt %d", a.attempt))
|
||||
c.log.Info("task given", "task", a.task.ID, "worker", w.name, "attempt", a.attempt)
|
||||
w.running[a.task.ID] = a
|
||||
keys := c.keys[a.job.ID]
|
||||
w.send.add(&pb.CoordinatorMessage{Kind: &pb.CoordinatorMessage_Assignment{Assignment: &pb.TaskAssignment{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package coordinator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
jobmodel "github.com/sisyphus-network/Sisyphus/packages/job-model"
|
||||
)
|
||||
|
||||
// A finished job's line in the coordinator's log says which machines did
|
||||
// its tasks, those that did most first.
|
||||
func TestAJobSaysWhichWorkersDidItsTasks(t *testing.T) {
|
||||
job := &jobmodel.Job{}
|
||||
for _, name := range []string{"r620", "laptop", "r620", "", "rig", "laptop", "r620", "box"} {
|
||||
job.Tasks = append(job.Tasks, &jobmodel.Task{NodeName: name})
|
||||
}
|
||||
if got, want := workedBy(job), "r620×3 laptop×2 box×1 rig×1"; got != want {
|
||||
t.Errorf("workedBy = %q, want %q", got, want)
|
||||
}
|
||||
if got := workedBy(&jobmodel.Job{}); got != "" {
|
||||
t.Errorf("a job no worker touched was worked by %q", got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -192,6 +194,14 @@ func TestKeyNewMakesAKeyFileAndNeverReplacesOne(t *testing.T) {
|
||||
t.Errorf("%v: error %v", args, err)
|
||||
}
|
||||
}
|
||||
// Asking for help gets help, and no file named for the asking.
|
||||
t.Chdir(t.TempDir())
|
||||
if _, err := cli(t, "key", "new", "--help"); !errors.Is(err, flag.ErrHelp) {
|
||||
t.Errorf("key new --help: %v, want help", err)
|
||||
}
|
||||
if _, err := os.Stat("--help"); !os.IsNotExist(err) {
|
||||
t.Errorf("key new --help made a file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateJobFromTheCommandLine(t *testing.T) {
|
||||
|
||||
@@ -293,7 +293,7 @@ func TestDaemonStopsWhenItsCoordinatorRemovesIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerboseDaemonLogsEachTask(t *testing.T) {
|
||||
func TestADaemonLogsEachTaskItGivesAndRuns(t *testing.T) {
|
||||
logs := captureLogs(t)
|
||||
addr := freeAddr(t)
|
||||
stop := startDaemon(t, "-v", "--listen", addr, "--slots", "1")
|
||||
@@ -303,8 +303,12 @@ func TestVerboseDaemonLogsEachTask(t *testing.T) {
|
||||
}
|
||||
stop()
|
||||
|
||||
if !strings.Contains(logs.String(), `level=DEBUG msg="task done"`) {
|
||||
t.Errorf("no per-task debug line in the log:\n%s", logs)
|
||||
// The worker says what it ran, and the coordinator whom it gave each
|
||||
// task to and, at the end, who did how many.
|
||||
for _, want := range []string{`level=INFO msg="task started"`, `level=INFO msg="task done"`, `msg="task given"`, `msg="task returned"`, `msg="job finished"`, `by=`} {
|
||||
if !strings.Contains(logs.String(), want) {
|
||||
t.Errorf("no %s in the log:\n%s", want, logs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pb "github.com/sisyphus-network/Sisyphus/packages/protocol/sisyphus/v1"
|
||||
"github.com/sisyphus-network/Sisyphus/packages/runtime"
|
||||
)
|
||||
|
||||
// asking is a workload whose tasks need a model, as a conversation's do,
|
||||
// and that does what its payload says: answers, fails, or waits to be
|
||||
// stopped.
|
||||
type asking struct{}
|
||||
|
||||
func (asking) Name() string { return "asking" }
|
||||
func (asking) Split(context.Context, runtime.Blobs, []byte, int) ([][]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (asking) Aggregate(context.Context, runtime.Blobs, [][]byte) ([]byte, error) { return nil, nil }
|
||||
func (asking) Needs([]byte) []string { return []string{"model:llama3.1:8b"} }
|
||||
func (asking) Execute(ctx context.Context, _ runtime.Blobs, payload []byte) ([]byte, error) {
|
||||
switch string(payload) {
|
||||
case "fail":
|
||||
return nil, errors.New("out of memory")
|
||||
case "wait":
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return []byte("an answer"), nil
|
||||
}
|
||||
|
||||
// A worker's own log says what it is doing for the pool: each task as it
|
||||
// starts, with the model it asks for, and how it ended.
|
||||
func TestAWorkerLogsEachTaskItIsGivenAndHowItEnded(t *testing.T) {
|
||||
log := new(lines)
|
||||
w := &Worker{Name: "laptop", Workloads: runtime.NewRegistry(asking{}, runtime.Primes{}), Log: slog.New(slog.NewTextHandler(log, nil))}
|
||||
w.execute(context.Background(), &pb.TaskAssignment{JobId: "j1", TaskId: "j1/0", Workload: "asking", Payload: []byte("answer")})
|
||||
w.execute(context.Background(), &pb.TaskAssignment{JobId: "j1", TaskId: "j1/1", Workload: "asking", Payload: []byte("fail")})
|
||||
stopped, stop := context.WithCancel(context.Background())
|
||||
stop()
|
||||
w.execute(stopped, &pb.TaskAssignment{JobId: "j1", TaskId: "j1/2", Workload: "asking", Payload: []byte("wait")})
|
||||
w.execute(context.Background(), &pb.TaskAssignment{JobId: "j2", TaskId: "j2/0", Workload: "primes", Payload: []byte(`{"from":1,"to":10}`)})
|
||||
// A private job's task names no model, even to its worker's log.
|
||||
w.execute(context.Background(), &pb.TaskAssignment{JobId: "j3", TaskId: "j3/0", Workload: "asking", Payload: []byte("answer"), Keys: []*pb.SealingKey{{}}})
|
||||
w.execute(context.Background(), &pb.TaskAssignment{JobId: "j4", TaskId: "j4/0", Workload: "nothing known"})
|
||||
|
||||
said := strings.Join(log.said, "")
|
||||
for _, want := range []string{
|
||||
`msg="task started" job=j1 task=j1/0 workload=asking attempt=0 model=llama3.1:8b`,
|
||||
`msg="task done" job=j1 task=j1/0 took=`,
|
||||
`output_bytes=9`,
|
||||
`level=WARN msg="task failed" job=j1 task=j1/1`,
|
||||
`error="out of memory"`,
|
||||
`msg="task stopped" job=j1 task=j1/2`,
|
||||
`msg="task started" job=j2 task=j2/0 workload=primes attempt=0` + "\n",
|
||||
`msg="task started" job=j3 task=j3/0 workload=asking attempt=0` + "\n",
|
||||
`msg="task failed" job=j4 task=j4/0`,
|
||||
} {
|
||||
if !strings.Contains(said, want) {
|
||||
t.Errorf("the log has no %q:\n%s", want, said)
|
||||
}
|
||||
}
|
||||
if strings.Contains(said, "task=j4/0 workload") {
|
||||
t.Errorf("a task of a workload the worker does not have was said to start:\n%s", said)
|
||||
}
|
||||
}
|
||||
@@ -273,6 +273,10 @@ func (w *Worker) swarmChanged(fingerprint string) {
|
||||
|
||||
func (w *Worker) execute(ctx context.Context, a *pb.TaskAssignment) (result *pb.TaskResult) {
|
||||
result = &pb.TaskResult{TaskId: a.GetTaskId(), Attempt: a.GetAttempt()}
|
||||
began := time.Now()
|
||||
// Each task the worker is given shows in its own log as it ends, so
|
||||
// that its owner sees what their machine is doing for the pool.
|
||||
defer func() { w.logOutcome(ctx, a, result, time.Since(began)) }()
|
||||
fail := func(err any) {
|
||||
result.Outcome = &pb.TaskResult_Error{Error: fmt.Sprint(err)}
|
||||
}
|
||||
@@ -288,6 +292,15 @@ func (w *Worker) execute(ctx context.Context, a *pb.TaskAssignment) (result *pb.
|
||||
fail(err)
|
||||
return result
|
||||
}
|
||||
started := []any{"job", a.GetJobId(), "task", a.GetTaskId(), "workload", a.GetWorkload(), "attempt", a.GetAttempt()}
|
||||
// The model a task asks for is named, but not for a private job, whose
|
||||
// owner may not want even that in a worker's log.
|
||||
if needy, ok := workload.(runtime.Needy); ok && !private(a) {
|
||||
if model := runtime.ModelNeeded(needy.Needs(a.GetPayload())); model != "" {
|
||||
started = append(started, "model", model)
|
||||
}
|
||||
}
|
||||
w.Log.Info("task started", started...)
|
||||
if w.Limit != nil {
|
||||
if !w.Limit.Acquire(ctx, w.Pool) {
|
||||
fail(ctx.Err())
|
||||
@@ -295,7 +308,6 @@ func (w *Worker) execute(ctx context.Context, a *pb.TaskAssignment) (result *pb.
|
||||
}
|
||||
defer w.Limit.Release(w.Pool)
|
||||
}
|
||||
started := time.Now()
|
||||
touched := runtime.Record(w.Blobs)
|
||||
// A job that is not private opens no sealed blob: it has no key to one.
|
||||
blobs := runtime.Open(touched)
|
||||
@@ -315,11 +327,23 @@ func (w *Worker) execute(ctx context.Context, a *pb.TaskAssignment) (result *pb.
|
||||
return result
|
||||
}
|
||||
result.ReadBlobs, result.WrittenBlobs = touched.Read(), touched.Written()
|
||||
w.Log.Debug("task done", "node", w.Name, "task", a.GetTaskId(), "took", time.Since(started).String())
|
||||
result.Outcome = &pb.TaskResult_Output{Output: output}
|
||||
return result
|
||||
}
|
||||
|
||||
// logOutcome says in the worker's log how a task it was given ended.
|
||||
func (w *Worker) logOutcome(ctx context.Context, a *pb.TaskAssignment, result *pb.TaskResult, took time.Duration) {
|
||||
said := []any{"job", a.GetJobId(), "task", a.GetTaskId(), "took", took.Round(time.Millisecond).String()}
|
||||
switch {
|
||||
case result.GetError() == "":
|
||||
w.Log.Info("task done", append(said, "output_bytes", len(result.GetOutput()))...)
|
||||
case ctx.Err() != nil:
|
||||
w.Log.Info("task stopped", said...)
|
||||
default:
|
||||
w.Log.Warn("task failed", append(said, "error", result.GetError())...)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) capabilities() *pb.NodeCapabilities {
|
||||
hostname, _ := os.Hostname()
|
||||
capabilities := &pb.NodeCapabilities{
|
||||
|
||||
Reference in new issue
Block a user